Files
horizon/crates/horizon-client/src/game/remote.rs
T
rdbtCVSandClaude Opus 5.5 23f8bd4fed Remove the deprecated in-frame single-player path
Single player only runs on its server thread now. --in-frame and
--no-server-thread print a notice, and HORIZON_SERVER_THREAD is ignored.
This deletes the code that only the in-frame path reached: the window's
world loader and local player tick, its portal travel, the loading screen
and ring-by-ring streaming, and the in-frame LAN host (the host player in
the hub, its models of the guests, the reserved uuid). The world-upgrade
progress screen with its Cancel button moves to the server-thread start.
Tools, the harnesses and the docs no longer use the in-frame path.

Closes #6473

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:30:14 +00:00

10837 lines
462 KiB
Rust

//! Multiplayer: the game driven by a server connection instead of the local
//! simulation. Mirrors vanilla's ClientPacketListener (chunks, entities,
//! inventory, health, time, weather, chat, respawn and dimension changes),
//! LocalPlayer.sendPosition (movement packets) and MultiPlayerGameMode
//! (block breaking with sequence numbers, use-item, attacks, container
//! clicks). The player still moves with the local physics, as the vanilla
//! client predicts its own movement; the server stays authoritative for
//! everything else.
use super::remote_chunks::{ChunkDecoder, ChunkFormat, Next};
use super::{Dim, Game, Mining, Screen};
use horizon_client::first_person::{Hand, SwingAnimation};
use horizon_client::{
Result,
blocks::Fluid,
chunk::Chunk,
containers::{BlockEntity, Furnace, FurnaceKind},
entities::{Experience, ExperienceOrb, ItemEntity},
hud::HudBatch,
items::ItemStack,
menu::Input,
mobs::{Mob, MobType, Who},
motion::{MotionWorld, PlayerMotion},
net::{Event, Session},
save::SaveDir,
screen_effects,
sprint::Keys,
streaming::{ChunkGenerator, Proto, Streaming},
tab_list::SPECTATOR,
world::World,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{ITEM_ENTITY_ITEM, Value},
item::{Hashed, Stack},
packets::{
Empty,
play::{self, Clientbound as C, Serverbound as S},
},
registry::{Builtin, DimensionType, Dynamic, SyncedNames, ceil_log2},
rsa::RsaPublicKey,
secure_chat::{self, Signature},
text::{Language, plain},
};
use horizon_world::{
level::InteractionResult,
nbt::Tag,
registry::BlockStateId,
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
};
use std::{
collections::{HashMap, VecDeque},
sync::Arc,
time::{Duration, Instant},
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// `ChatType`'s two decorations: how a message shows in the chat, and what
/// the narrator says of it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Decoration {
Chat,
Narration,
}
/// `ChatTypeDecoration.decorate` of a chat type's `chat` or `narration`
/// entry: its translation, with the sender, the target or the message as its
/// parameters, in its style. A chat type the registry lacks decorates as
/// `minecraft:chat` (`chat.type.text`, narrated `chat.type.text.narrate`).
fn chat_type_decorate(
chat_type: Option<&Tag>,
decoration: Decoration,
bound: &play::ChatTypeBound,
content: &serde_json::Value,
) -> serde_json::Value {
use horizon_client::scoreboard::tag_to_json;
let (field, default_key) = match decoration {
Decoration::Chat => ("chat", "chat.type.text"),
Decoration::Narration => ("narration", "chat.type.text.narrate"),
};
let decoration = chat_type.and_then(|t| t.get(field));
let key = match decoration.and_then(|d| d.get("translation_key")) {
Some(Tag::String(s)) => s.to_string_lossy(),
_ => default_key.into(),
};
let parameter = |name: &str| match name {
"sender" => tag_to_json(&bound.name),
"target" => bound
.target
.as_ref()
.map_or_else(|| serde_json::Value::from(""), tag_to_json),
_ => content.clone(),
};
let with: Vec<serde_json::Value> = match decoration.and_then(|d| d.get("parameters")) {
Some(Tag::List(list)) => list
.iter()
.map(|p| match p {
Tag::String(s) => parameter(&s.to_string_lossy()),
_ => serde_json::Value::from(""),
})
.collect(),
_ => vec![parameter("sender"), parameter("content")],
};
let mut component = match decoration.and_then(|d| d.get("style")) {
Some(style @ Tag::Compound(_)) => tag_to_json(style),
_ => serde_json::json!({}),
};
component["translate"] = key.into();
component["with"] = with.into();
component
}
/// A text component the server sent, for the HUD.
pub(super) fn remote_text(tag: &Tag) -> horizon_client::text::Text {
use horizon_client::scoreboard::{comp_text, tag_to_json};
comp_text(&tag_to_json(tag))
}
/// The hurt sound of a `ClientboundDamageEventPacket`'s damage type, by its
/// synchronized registry id: `Player.getHurtSound` picks the type's effects.
fn hurt_sound_for(
registries: &Dynamic,
damage: &horizon_world::damage_source::Catalog,
source_type: i32,
) -> &'static str {
if let Some(Tag::String(effects)) = registries
.data("minecraft:damage_type", source_type)
.and_then(|t| t.get("effects"))
&& let Ok(effects) = effects.to_string()
{
return horizon_client::health::hurt_sound(&effects);
}
registries
.name("minecraft:damage_type", source_type)
.and_then(|name| damage.id(name).and_then(|id| damage.definition(id)))
.map_or(horizon_client::health::hurt_sound("hurt"), |d| {
horizon_client::health::hurt_sound(&d.effects)
})
}
/// Position of the entity a `ClientboundSoundEntityPacket` names, if this level
/// has it and `EntityBoundSoundInstance.canPlaySound` (`!entity.isSilent()`)
/// holds. The local player is not a tracked entity; removed ids leave the
/// tracked table.
fn entity_position(
id: i32,
player: Option<(i32, [f64; 3])>,
mobs: &[Mob],
tracked: &HashMap<i32, Tracked>,
) -> Option<[f64; 3]> {
if let Some((player_id, at)) = player
&& player_id == id
{
return Some(at);
}
if let Some(m) = mobs.iter().find(|m| m.id as i32 == id) {
return (!m.silent).then_some([m.position.x, m.position.y, m.position.z]);
}
tracked.get(&id).map(|t| t.position)
}
/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the
/// component's styles, and the `§` codes a literal holds (`StringDecomposer`).
fn menu_title(tag: &Tag) -> horizon_client::text::Text {
remote_text(tag).resolve_legacy()
}
/// A player message Chat Delay holds back, its signature and trust level,
/// and what the narrator says of it once it shows.
type QueuedChat = (
horizon_client::text::Text,
horizon_client::chat::ChatSource,
Option<Signature>,
ChatTrust,
String,
);
/// `ChatTrustLevel`; a modified message keeps the signed content it was
/// changed from.
pub(super) enum ChatTrust {
Secure,
Modified(String),
NotSecure,
/// `GuiMessageTag.chatError`: the line `ChatListener.handleChatMessageError` adds.
Error,
}
impl ChatTrust {
/// `ChatTrustLevel.createTag`.
fn tag(&self) -> Option<horizon_client::chat::Tag> {
match self {
Self::Secure => None,
Self::Modified(original) => Some(horizon_client::chat::Tag::modified(original)),
Self::NotSecure => Some(horizon_client::chat::Tag::not_secure()),
Self::Error => Some(horizon_client::chat::Tag::chat_error()),
}
}
}
/// Streaming needs a generator; a server world never generates.
struct NoTerrain;
impl ChunkGenerator for NoTerrain {
type Worker = ();
fn worker(&self) {}
fn proto(&self, _: &mut (), _: i32, _: i32) -> Proto {
Box::new(())
}
fn finish(&self, _: Proto) -> Chunk {
Chunk::empty(0, 0)
}
}
/// A server entity the client tracks (position as last sent).
struct Tracked {
kind: String,
position: [f64; 3],
}
/// Smooth an entity toward a new position over three ticks (InterpolationHandler).
#[derive(Clone, Copy)]
/// `LinearInterpolationHandler`'s target: a position and rotation (yRot,
/// xRot) reached in `steps` ticks.
struct Lerp {
target: [f64; 3],
rotation: Option<(f32, f32)>,
steps: u32,
}
/// `NewMinecartBehavior.MinecartStep` (its xRot is not kept: a minecart here
/// pitches along its rails).
#[derive(Clone, Copy)]
struct CartStep {
position: Vec3,
movement: Vec3,
yaw: f32,
weight: f32,
}
/// The client's state of a minecart on `NewMinecartBehavior`: the steps the
/// server sent (`lerpSteps`), the ones being played over 3 ticks
/// (`currentLerpSteps`) and where the cart was when they began (`oldLerp`).
struct CartLerp {
pending: Vec<CartStep>,
current: Vec<CartStep>,
total_weight: f64,
delay: i32,
old: CartStep,
}
impl CartLerp {
fn new() -> Self {
Self {
pending: Vec::new(),
current: Vec::new(),
total_weight: 0.0,
delay: 0,
old: CartStep {
position: Vec3::ZERO,
movement: Vec3::ZERO,
yaw: 0.0,
weight: 0.0,
},
}
}
/// `NewMinecartBehavior.getCartLerpPosition`, `getCartLerpMovements` and
/// `getCartLerpYRot` at `partial`: the step the weights put this moment
/// in, and how far along it, between it and the one before.
fn at(&self, partial: f32) -> (Vec3, Vec3, f32) {
let alpha = ((3 - self.delay) as f32 + partial) / 3.0;
let mut count_up = 0.0f32;
let mut in_step = 1.0f32;
let mut found = None;
for (i, s) in self.current.iter().enumerate() {
if s.weight <= 0.0 {
continue;
}
count_up += s.weight;
if f64::from(count_up) >= self.total_weight * f64::from(alpha) {
let before = count_up - s.weight;
in_step = ((f64::from(alpha) * self.total_weight - f64::from(before))
/ f64::from(s.weight)) as f32;
found = Some(i);
break;
}
}
let index = found.unwrap_or(self.current.len() - 1);
let current = self.current[index];
let previous = if index > 0 {
self.current[index - 1]
} else {
self.old
};
let t = f64::from(in_step);
let lerp = |a: Vec3, b: Vec3| {
Vec3::new(
a.x + t * (b.x - a.x),
a.y + t * (b.y - a.y),
a.z + t * (b.z - a.z),
)
};
(
lerp(previous.position, current.position),
lerp(previous.movement, current.movement),
previous.yaw + in_step * wrap_degrees(current.yaw - previous.yaw),
)
}
/// `NewMinecartBehavior.lerpClientPositionAndRotation`: once the steps
/// being played are used up, the queued ones become the current ones;
/// the cart then sits where the steps put it at the end of this tick.
/// False when it has no steps to play.
fn tick(&mut self, c: &mut super::minecarts::Minecart) -> bool {
self.delay -= 1;
if self.delay <= 0 {
self.old = CartStep {
position: c.position,
movement: c.velocity,
yaw: c.yaw,
weight: 0.0,
};
self.current.clear();
if !self.pending.is_empty() {
self.current.append(&mut self.pending);
self.total_weight = self.current.iter().map(|s| f64::from(s.weight)).sum();
self.delay = if self.total_weight == 0.0 { 0 } else { 3 };
}
}
if self.current.is_empty() {
return false;
}
let (position, movement, yaw) = self.at(1.0);
c.position = position;
c.velocity = movement;
c.yaw = yaw;
true
}
}
/// `SteppedInterpolationHandler.doInterpolate`'s `setPos` and `setRot` on a
/// mob: its yaw kept continuous (the same turn as vanilla's, without the
/// wrap that would spin it in the frame between ticks), its pitch clamped.
fn place_interpolated(m: &mut Mob, at: PosRot) {
m.position = Vec3::new(at.position[0], at.position[1], at.position[2]);
m.yaw += wrap_degrees(at.yaw - m.yaw);
m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0);
}
/// The packet a living entity's move came in.
#[derive(Clone, Copy, PartialEq)]
enum MovePacket {
/// `handleMoveEntity`: always interpolated.
Move,
/// `handleEntityPositionSync`.
PositionSync,
/// `handleTeleportEntity` (`setValuesFromPositionPacket`).
Teleport,
}
impl MovePacket {
/// `tooBigToInterpolate`: a move to `to` of more than 64 blocks snaps.
/// A position sync measures it from where the entity is, a teleport from
/// where it is headed (`PositionMoveRotation.of`'s
/// `getClientPositionAndRotation`).
fn snaps(self, handler: &SteppedInterpolation, entity: PosRot, to: [f64; 3]) -> bool {
let from = match self {
MovePacket::Move => return false,
MovePacket::PositionSync => entity.position,
MovePacket::Teleport => handler.heading(entity).position,
};
let d = [0, 1, 2].map(|i| to[i] - from[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 4096.0
}
}
/// A move `packet` to `to` for a living entity at `entity`, through its
/// handler (`interpolateTo`): where it is put at once, if anywhere. A move
/// too big to interpolate is a `snapTo`; vanilla leaves the handler running
/// and its next step shifts what is left by the jump
/// (`adjustInterpolationTargetFromDeltas`), which this handler doesn't port,
/// so the steps are dropped instead. That is the same for an entity that
/// wasn't moving, as a teleported one usually isn't.
fn stepped_move(
handler: &mut SteppedInterpolation,
packet: MovePacket,
entity: PosRot,
to: [f64; 3],
path: Option<Path>,
rotation: Option<(f32, f32)>,
) -> Option<PosRot> {
if packet.snaps(handler, entity, to) {
handler.cancel();
let (yaw, pitch) = rotation.unwrap_or((entity.yaw, entity.pitch));
return Some(PosRot {
position: to,
yaw,
pitch,
});
}
match handler.interpolate_to(entity, path, rotation) {
Start::Snap(at) => Some(at),
Start::Interpolating | Start::Unchanged => None,
}
}
/// What the network thread delivered, in order: an event, or a chunk packet
/// handed to the decoder (with the format it decodes with), whose column
/// comes back from `ChunkDecoder::next`.
enum Received {
Event(Event),
Chunk(Arc<ChunkFormat>),
}
/// `Minecraft.services().profileKeySignatureValidator()`: the services' profile-key keys, which a
/// player's chat key must be signed by. Asked for once per connection to a server, off the game
/// thread.
pub(super) enum ServicesKeys {
Pending,
Ready(Vec<RsaPublicKey>),
/// `canValidateProfileKeys()` is false: none could be had.
Missing,
}
/// The signed-in account's `/minecraft/profile` fetch after joining.
enum OwnProfile {
Idle,
Fetching(std::sync::mpsc::Receiver<Option<horizon_client::skins::Textures>>),
Done(Option<horizon_client::skins::Textures>),
}
pub(super) struct Remote {
pub session: Session,
pub brand: String,
/// The server announced it runs mods' quiet commands.
pub quiet_commands: bool,
/// The server announced it carries mod messages.
pub mod_net: bool,
/// The server samples far terrain for us, this far at most (`lod_net`).
pub far_terrain: Option<horizon_client::lod_net::Offer>,
pub(super) builtin: Arc<Builtin>,
lang: Arc<Language>,
pub(super) registries: Arc<Dynamic>,
pub(super) joined: bool,
/// `ServerReconfigScreen` is up since this tick.
pub(super) reconfiguring: Option<u64>,
/// `handleLogin` ran: vanilla's `Minecraft.player` exists from then on,
/// while here the player is made at the first teleport.
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,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
/// Decodes chunk packets as they arrive, off the frame.
decoder: ChunkDecoder,
/// Options.serverRenderDistance: the server's view distance from the
/// login or the last chunk-cache-radius packet.
pub(super) view_distance: i32,
/// `ClientLevel.serverSimulationDistance`: from the login or the last
/// simulation-distance packet.
pub(super) simulation_distance: i32,
sequence: i32,
position_sender: horizon_client::player_sync::PositionSender,
last_input: u8,
was_sprinting: bool,
carried: Option<usize>,
/// The last ClientboundPlayerAbilitiesPacket. Vanilla's LocalPlayer
/// holds its abilities from the login on; here the player is made at
/// the first teleport, which takes them then.
abilities: Option<play::PlayerAbilities>,
loaded_sent: bool,
/// The dimension of the client level the mods heard made
/// (`native_client_level_load`), to be told dropped once.
heard_level: Option<String>,
/// `ClientPacketListener.levelLoadTracker`: the loading screen after a login
/// or respawn, until the player's section is shown.
pub(super) level_load: horizon_client::level_loading::LevelLoadTracker,
/// The ClientInformation last sent (`Options.broadcastOptions` sends it
/// again when a view distance, Chat, skin or Main Hand option changes).
sent_info: horizon_protocol::packets::configuration::ClientInformation,
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
/// with the sequence of the last action that changed them and the state the server
/// last said they have. Until the server acknowledges that sequence its block updates
/// only replace the remembered state; then the block takes it.
predictions: HashMap<[i32; 3], (i32, i32)>,
/// Open container: (window id, menu name).
pub(super) menu: Option<(i32, String)>,
/// The open menu's title as the server named it, with its styles.
pub(super) menu_title: Option<horizon_client::text::Text>,
state_id: i32,
use_target: Option<[i32; 3]>,
entities: HashMap<i32, Tracked>,
lerps: HashMap<i32, Lerp>,
/// The minecarts the server moves with `ClientboundMoveMinecartPacket`
/// (`NewMinecartBehavior`), which have no interpolation handler.
cart_lerps: HashMap<i32, CartLerp>,
/// Living entities' `SteppedInterpolationHandler`s.
stepped: HashMap<i32, SteppedInterpolation>,
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
head_lerps: HashMap<i32, (f32, u32)>,
/// 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)>,
health: (f32, i32, f32),
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
/// sends; from then on a drop starts the hurt animation.
flash_on_set_health: bool,
game_mode: i32,
pub chat: Vec<(String, Instant)>,
/// Lines received since joining, for the control socket's events.
pub chat_received: u64,
/// `ChatListener.delayedMessageQueue`: player chat held back by Chat
/// Delay, with the signature a deletion names it by and what the
/// narrator says once it shows.
chat_queue: VecDeque<QueuedChat>,
/// `ClientPacketListener.messageSignatureCache`: the signatures packed
/// ones index, as the server's copy for this connection holds them.
pub(super) signatures: horizon_protocol::secure_chat::SignatureCache,
/// `ChatListener.previousMessageTime`: when a message last showed, if one has.
chat_previous: Option<Instant>,
/// `ClientPacketListener.nextChatIndex`: the global index the next chat packet must carry.
next_chat_index: i32,
/// `serverEnforcesSecureChat`, from the login packet.
enforces_secure_chat: bool,
services_keys: Arc<std::sync::Mutex<ServicesKeys>>,
/// The `RemoteChatSession.Data` each player's list entry last carried (INITIALIZE_CHAT).
chat_sessions: HashMap<u128, Option<(u128, i64, Vec<u8>, Vec<u8>)>>,
/// `PlayerInfo.messageValidator` of the players whose chat has been read.
chat_validators: HashMap<u128, secure_chat::MessageValidator>,
/// `ConnectScreen.lastNarration`: when "Joining the world" was last said.
joining_narrated: Option<Instant>,
/// The tree the chat colours, completes and parses with: the server's
/// (ClientboundCommandsPacket) once it has sent one, else the game's own,
/// plus the commands native mods own (`chat::chat_tree_for`). Rebuilt where
/// that base changes, not at each keystroke.
pub chat_tree: horizon_client::command::Dispatcher,
/// Whether a `ClientboundCommandsPacket` has supplied the server's tree.
/// The local fallback is for UI completion only; vanilla signs command
/// arguments against an empty tree until the server sends one.
pub(super) chat_tree_received: bool,
/// The chat session, the last-seen window and the signature cache.
pub(super) secure_chat: super::remote_chat::SecureChat,
/// `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.
tab_players: HashMap<u128, horizon_client::tab_list::TabPlayer>,
/// Each listed profile's unpacked `textures` property.
skin_textures: HashMap<u128, horizon_client::skins::Textures>,
/// Active skin and cape from the services profile when the server sent none.
own_profile: OwnProfile,
/// The skin texture each profile's players show, once its download is ready.
skins: HashMap<u128, String>,
listed: std::collections::HashSet<u128>,
/// `ClientboundLoginPacket.onlineMode`: the player list shows heads.
pub(super) online_mode: bool,
/// Player entities in the level: entity id to profile UUID.
level_players: HashMap<i32, u128>,
/// The profile name each player entity was created with (`RemotePlayer.getGameProfile`): it outlives the
/// player-list entry, which servers remove to hide their NPCs from the list.
level_player_names: HashMap<i32, String>,
/// The `textures` each player entity found in the player list first
/// (`AbstractClientPlayer.getPlayerInfo` keeps that `PlayerInfo`): its skin
/// outlives the entry, which NPC plugins remove a moment after spawning.
level_player_textures: HashMap<i32, horizon_client::skins::Textures>,
/// 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,
/// `LivingEntity.DATA_ARROW_COUNT_ID` and `DATA_STINGER_COUNT_ID` of the
/// own player, as the server synced them.
pub(super) own_arrows: i32,
pub(super) own_stingers: i32,
/// `Player.DATA_SHOULDER_PARROT_LEFT` and `_RIGHT` of the own player.
pub(super) own_shoulders: [Option<i32>; 2],
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
pub(super) profile_name: String,
/// `MultiPlayerGameMode.destroyBlockPos`, `.destroyProgress` and the face last sent: the block
/// the break targets, which outlives the break itself.
destroying: Option<([i32; 3], u8, f32)>,
/// `MultiPlayerGameMode.destroyingItem`: the held item key the break began with.
destroying_item: String,
/// `MultiPlayerGameMode.isDestroying`: a break is running. `destroying` keeps the target after
/// one ends (`destroyBlockPos` is written only when a break starts), so a dig resumed on the
/// same block and item sends no new START and the server keeps its clock (#4124).
is_destroying: bool,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
/// Keys whose presses send a packet (swap hands, drop, a mount's
/// 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.
pub(super) flat: bool,
death_message: Option<horizon_client::text::Text>,
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
/// `LocalPlayer.doLimitedCrafting`: whether limited crafting is enabled.
pub(super) do_limited_crafting: bool,
player_type: Option<Arc<MobType>>,
/// Inventory, off hand and armour as last synchronised, for creative-mode
/// slot updates (`creative_sync_stacks`).
synced: Vec<Option<ItemStack>>,
decode_errors: u32,
clock: Option<i32>,
/// `ClientClockManager.ClientClockInstance`'s rate and partial tick for
/// the level's clock (its total is `world.day_time`), and
/// `lastTickGameTime`.
clock_rate: f32,
clock_partial: f32,
clock_game_time: i64,
/// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket).
chunk_center: (i32, i32),
/// Received events a frame left for the next one, in order, and when
/// the oldest of them first waited (`poll_remote`).
backlog: VecDeque<Received>,
backlog_since: Option<Instant>,
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
transfer: Option<String>,
/// `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
/// the received events for the next frame. A batch of 25 to 60 columns
/// (0.1 ms each on an M4 Pro) all in one frame was a 3 to 8 ms frame every
/// time the player crossed a chunk border at 32 chunks; they decode on the
/// chunk decoder's thread now, and the frame only inserts them.
const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1);
/// Events that have waited this long are all handled in the next frame,
/// whatever inserting their columns costs, up to a column still decoding:
/// the packets after a slow machine's chunks don't wait on its frames.
const BACKLOG_LIMIT: Duration = Duration::from_millis(50);
/// The open menu name of a server's horse inventory, then its chest's
/// columns (the packet names no `MenuType`).
const MOUNT_MENU: &str = "horizon:mount";
fn face_direction(face: [i32; 3]) -> u8 {
match face {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
[1, 0, 0] => 5,
_ => 1,
}
}
/// A protocol number format as the scoreboard's.
fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFormat {
use horizon_client::scoreboard::{NumberFormat, tag_to_json};
match f {
play::NumberFormat::Blank => NumberFormat::Blank,
play::NumberFormat::Styled(t) => NumberFormat::Styled(tag_to_json(t)),
play::NumberFormat::Fixed(t) => NumberFormat::Fixed(tag_to_json(t)),
}
}
impl Game<'_> {
/// A `Holder<SoundEvent>` off the wire: an inline event's location, or
/// the name of a registered one.
pub(super) fn remote_sound_name(
&self,
sound: &(i32, Option<(String, Option<f32>)>),
) -> Option<String> {
match &sound.1 {
Some((location, _)) => Some(location.clone()),
None => self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:sound_event")?
.name(sound.0)
.map(str::to_owned)
}),
}
}
/// Position of an entity `ClientboundSoundEntityPacket` names.
fn remote_entity_position(&self, id: i32) -> Option<[f64; 3]> {
let r = self.remote.as_ref()?;
let player = self
.player
.as_ref()
.map(|p| (r.entity_id, [p.position.x, p.position.y, p.position.z]));
entity_position(id, player, &self.mobs, &r.entities)
}
/// The hurt sound the hurt player's own client plays from
/// `ClientboundDamageEventPacket` (`LivingEntity.handleDamageEvent`):
/// `Player.getHurtSound` is the damage type's effects, with the codec's
/// `hurt` default when the type is unknown.
pub(super) fn remote_hurt_sound(&self, source_type: i32) -> &'static str {
match self.remote.as_ref() {
Some(r) => hurt_sound_for(&r.registries, &self.motion.health.damage, source_type),
None => horizon_client::health::hurt_sound("hurt"),
}
}
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
self.connect_server_with_cookies(address, name, None)
}
/// `connect_server` carrying a transfer's stored server cookies
/// (`TransferState`); None on an ordinary join.
pub(super) fn connect_server_with_cookies(
&mut self,
address: &str,
name: &str,
cookies: Option<horizon_client::net::Cookies>,
) -> Result<()> {
self.connect_server_with_options(address, name, cookies, false)
}
/// `ConnectScreen.startConnecting`: keep LAN discovery's server kind through loading.
pub(super) fn connect_server_with_options(
&mut self,
address: &str,
name: &str,
cookies: Option<horizon_client::net::Cookies>,
lan: bool,
) -> Result<()> {
super::world_loading::preload_server_loot(&self.options.resources);
if self.defer_world_entry(super::PendingWorldEntry::Server {
address: address.to_owned(),
name: name.to_owned(),
cookies: cookies.clone(),
lan,
})? {
return Ok(());
}
self.join_world_rest()?;
if let Err(error) = self.join_server(address, name, None, cookies, lan) {
log::warn!("Horizon: could not join server: {error}");
if self.title.is_none() {
self.title = Some(
horizon_client::title::Title::new(
std::env::current_dir()?.join("saves"),
&self.options.resources,
)
.with_user(|| self.options.player_name()),
);
}
let skip = self.settings.file.bool("skipMultiplayerWarning");
if let Some(title) = &mut self.title {
title.skip_multiplayer_warning = skip;
title.failed(error);
}
self.capture(false);
}
Ok(())
}
/// Join single player's integrated server through memory.
pub(super) fn connect_local(
&mut self,
pipe: horizon_protocol::connection::Pipe,
name: &str,
) -> Result<()> {
self.join_server("local", name, Some(pipe), None, false)
}
fn join_server(
&mut self,
address: &str,
name: &str,
local: Option<horizon_protocol::connection::Pipe>,
cookies: Option<horizon_client::net::Cookies>,
lan: bool,
) -> Result<()> {
self.reset_tutorial();
self.perf.world_opening();
// A server's own biomes come over the connection; the last local
// world's packed table must not name its ids.
self.world_biomes = None;
let root = self.options.resources.clone();
let builtin = Builtin::find(&root)
.and_then(|p| Builtin::load(&p).ok())
.ok_or("Resources are incomplete: registries.json is missing or invalid. Click \"Prepare again\" in the launcher (or run horizon-prepare), then try again.")?;
let lang = Arc::new(horizon_client::net::load_language(&root));
// Options.createInformation: what the server learns about this player,
// sent again when one of those options changes.
let info = self
.settings
.client_information(client_view_distance(self.render_distance_in_use()));
if local.is_none() && !self.options.split_guest {
horizon_client::crash::set_detail(
"Server",
horizon_client::net::shown_address(address),
);
}
// A signed-in account always plays under its own name.
let auth = horizon_client::auth::current();
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
let user = auth.as_ref().map_or_else(
|| {
(
name.to_owned(),
horizon_protocol::crypto::offline_uuid(name),
)
},
|a| (a.name.clone(), a.uuid),
);
self.server_packs_connect(local.is_none().then_some(address), user);
let session = match local {
Some(pipe) => {
Session::connect_local(pipe, name, info.clone(), lang.clone(), root.clone())
}
None => {
self.extras_connecting(address, name, lan);
match cookies {
Some(cookies) => Session::connect_transferred(
address,
name,
auth.clone(),
info.clone(),
lang.clone(),
cookies,
root.clone(),
),
None => {
let connect = if lan {
Session::connect_lan
} else {
Session::connect
};
connect(
address,
name,
auth.clone(),
info.clone(),
lang.clone(),
root.clone(),
)
}
}
}
};
let player_type = super::perspective::player_type(&root);
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
self.pack.context.clone(),
self.res.threads(),
);
streaming.remote = true;
streaming.radius = self.render_distance_in_use();
self.streaming = streaming;
self.world = World::new(self.res.facts().air, 0);
self.save = Arc::new(SaveDir::new(
std::env::temp_dir().join("horizon-remote-unused"),
horizon_client::save::LevelShape::OVERWORLD,
));
self.inventory = vec![None; 36];
self.armor = [None, None, None, None];
self.craft_grid = vec![None; 9];
self.craft_size = 2;
self.player = None;
self.spawn = None;
self.mobs.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
self.creatures.projectiles.clear();
self.dropped.clear();
self.particles.clear();
self.pickups.clear();
self.orbs.clear();
self.falling.clear();
self.mining = None;
self.container = None;
self.carried = None;
self.using = None;
self.end_mod_use();
self.clicks.miss_time = 0;
// A new LocalPlayer has no effects: the server sends what it has
// (ClientboundUpdateMobEffectPacket on placement).
self.potions = Default::default();
self.set_mode_id(0);
self.experience = Experience {
level: 0,
progress: 0.0,
total: 0,
};
// The connected client never opens a server world (single player also
// joins here), so load the registries its argument parsers and local
// suggestions need before receiving the server's command tree.
self.ui.registries = Default::default();
self.build_registries();
self.ui.registries.permission = 0;
self.ui.chat.clear();
self.screen = Screen::Playing;
self.title = None;
self.dim = Dim::Overworld;
self.clear_lod();
self.pending_uploads.clear();
if let Some(r) = self.renderer.as_mut() {
r.clear_sections(self.world.layout());
}
self.apply_settings();
self.clear_waypoints();
self.boss_overlay.reset();
// The chat's tree until the server sends its own, or forever when it
// sends none: the game's own, plus the commands native mods own.
let chat_tree = self.chat_tree_for(None);
// Minecraft.services() has the keys wherever the game is played, single player's own
// server included: its guests' chat keys are checked against them.
let services_keys = Arc::new(std::sync::Mutex::new(ServicesKeys::Pending));
let slot = services_keys.clone();
let _ = std::thread::Builder::new()
.name("Services keys".into())
.spawn(move || {
use horizon_server_files::profiles;
let keys = profiles::fetch_profile_key_keys(&profiles::discovery_url());
*slot.lock().unwrap_or_else(|e| e.into_inner()) = match keys {
Ok(keys) if !keys.is_empty() => ServicesKeys::Ready(keys),
Ok(_) => ServicesKeys::Missing,
Err(why) => {
log::error!("Horizon: {why}");
ServicesKeys::Missing
}
};
});
self.remote = Some(Remote {
session,
brand: "unknown".into(),
quiet_commands: false,
mod_net: false,
far_terrain: None,
builtin: Arc::new(builtin),
status: lang
.get("connect.connecting")
.cloned()
.unwrap_or_else(|| "Connecting to the server...".into()),
lang,
registries: Arc::new(Dynamic::default()),
joined: false,
reconfiguring: None,
logged_in: false,
entity_id: 0,
camera: None,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
Vec::new(),
7,
)),
decoder: ChunkDecoder::new(),
view_distance: self.settings.render_distance,
simulation_distance: self.settings.simulation_distance,
sequence: 0,
position_sender: Default::default(),
last_input: 0,
was_sprinting: false,
carried: None,
abilities: None,
loaded_sent: false,
heard_level: None,
level_load: horizon_client::level_loading::LevelLoadTracker::new(0),
sent_info: info,
predictions: HashMap::new(),
menu: None,
menu_title: None,
state_id: 0,
use_target: None,
entities: HashMap::new(),
lerps: HashMap::new(),
cart_lerps: HashMap::new(),
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,
game_mode: 0,
chat: Vec::new(),
chat_received: 0,
chat_queue: VecDeque::new(),
signatures: Default::default(),
chat_previous: None,
next_chat_index: 0,
enforces_secure_chat: false,
services_keys,
chat_sessions: HashMap::new(),
chat_validators: HashMap::new(),
joining_narrated: None,
chat_tree,
chat_tree_received: false,
secure_chat: Default::default(),
custom_completions: Default::default(),
players: HashMap::new(),
tab_players: HashMap::new(),
skin_textures: HashMap::new(),
own_profile: OwnProfile::Idle,
skins: HashMap::new(),
listed: Default::default(),
online_mode: false,
level_players: HashMap::new(),
level_player_names: HashMap::new(),
level_player_textures: HashMap::new(),
glowing: Default::default(),
own_invisible: false,
own_arrows: 0,
own_stingers: 0,
own_shoulders: [None; 2],
profile: 0,
profile_name: String::new(),
destroying: None,
destroying_item: String::new(),
is_destroying: false,
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
swap_answer: [false; 2],
stop_mining: false,
placing: None,
flat: false,
death_message: None,
show_death_screen: true,
do_limited_crafting: false,
player_type,
synced: vec![None; CREATIVE_SYNCED],
decode_errors: 0,
clock: None,
clock_rate: 1.0,
clock_partial: 0.0,
clock_game_time: 0,
chunk_center: (0, 0),
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.
self.clock = horizon_client::clock::FixedStep::client();
// The player's advancements and statistics are the server's.
self.adv = Default::default();
self.stats = Default::default();
Ok(())
}
/// Disconnect and return to the multiplayer screen (DisconnectedScreen).
pub(super) fn leave_server(&mut self, reason: Option<String>) -> Result<()> {
if reason.is_none() && self.integrated.as_ref().is_some_and(|s| !s.stopped()) {
return self.begin_closing_save(false);
}
self.finish_perf();
if reason.is_none() {
// Leaving on purpose: nothing for auto-reconnect to retry.
self.extras_left();
}
self.clear_waypoints();
self.hud_disconnected();
// Vanilla's Hud also ticks on the title and connect screens, so the
// action bar has run out by the next world; Horizon's ticks in one.
self.titles.overlay_time = 0;
// GameRenderer.resetData and narrator.clear (Minecraft.disconnect).
self.item_activation.reset();
// Kept mod block entity geometry belongs to the world leaving.
self.kept_block_entities.get_mut().clear();
self.clear_narrator();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
// Minecraft.disconnect: the client level goes.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
super::lan::forget_sync_data();
self.gui.atlas.object_skins.clear();
self.server_packs_disconnect();
self.code_of_conduct = None;
self.reset_tutorial();
self.close_dialogs();
// Gui.setScreen -> KeyMapping.releaseAll: the title or disconnect
// screen releases every held key. Its release goes to that screen,
// so a key kept here would still be down in the next world (#3246).
self.keys.clear();
self.left = false;
self.right = false;
// The shared world is closing: its invite code ends with it.
self.friends_lan_closed();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
server.stop()?;
true
}
None => false,
};
// A splitscreen player leaving says nothing of where P1 plays.
if !self.options.split_guest {
horizon_client::crash::clear_detail("Server");
}
self.clock = Default::default();
self.adv = Default::default();
self.stats = Default::default();
self.score.board = Default::default();
self.streaming = Streaming::new(Arc::new(NoTerrain), self.pack.context.clone(), 1);
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
// The vehicle went with the level: an id kept here would seat the
// next login on an entity that is not there.
self.riding = None;
// Out of the server's bed: InBedChatScreen goes with the level.
self.sleeping = None;
self.sleep_timer = 0;
self.mobs.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
self.dropped.clear();
self.particles.clear();
self.pickups.clear();
self.score.board = Default::default();
self.tab_list = Default::default();
self.border = Default::default();
self.screen = Screen::Playing;
self.container = None;
self.clear_lod();
self.pending_uploads.clear();
if let Some(r) = self.renderer.as_mut() {
r.clear_sections(self.world.layout());
}
let saves = std::env::current_dir()?.join("saves");
let mut title = horizon_client::title::Title::new(saves.clone(), &self.options.resources)
.with_user(|| self.options.player_name());
title.last_server = self.settings.file.string("lastServer");
title.skip_multiplayer_warning = self.settings.file.bool("skipMultiplayerWarning");
if single_player && reason.is_none() {
self.title = Some(title);
self.capture(false);
return Ok(());
}
title.mode = horizon_client::title::Mode::Multiplayer(match reason {
Some(reason) => horizon_client::multiplayer::Screen::Disconnected {
reason,
retry: None,
},
None => horizon_client::multiplayer::Screen::list(&saves),
});
self.title = Some(title);
self.capture(false);
Ok(())
}
/// `Hud.onDisconnected`, from `Minecraft.disconnect` and `clearClientLevel`
/// (a proxy's reconfiguration too), but the chat: joining clears it and a
/// reconfiguration keeps it (`ChatComponent.storeState`).
fn hud_disconnected(&mut self) {
self.tab_list.reset();
self.boss_overlay.reset();
self.adv.toasts.clear();
self.debug_charts.reset();
self.titles.clear();
self.titles.reset_times();
}
pub(super) fn send(&self, packet: S) {
if let Some(r) = &self.remote {
r.session.send(packet);
}
}
fn next_sequence(&mut self) -> i32 {
let r = self.remote.as_mut().expect("remote");
r.sequence += 1;
r.sequence
}
fn item_name(&self, id: i32) -> Arc<str> {
let r = self.remote.as_ref().expect("remote");
Arc::from(
r.builtin
.get("minecraft:item")
.and_then(|i| i.name(id))
.unwrap_or("minecraft:air"),
)
}
/// The server's network id of a mob effect (`brewing::EFFECTS` id).
pub(super) fn remote_effect_network_id(&self, effect: &str) -> Option<i32> {
let r = self.remote.as_ref()?;
r.builtin
.get("minecraft:mob_effect")?
.id(&format!("minecraft:{effect}"))
}
/// A mob effect's network id as the effect (`brewing::EFFECTS` id).
pub(super) fn remote_effect_id(&self, effect: i32) -> Option<&'static str> {
let r = self.remote.as_ref()?;
let name = r.builtin.get("minecraft:mob_effect")?.name(effect)?;
horizon_client::brewing::effect(name).map(|d| d.id)
}
/// ClientPacketListener.handleUpdateMobEffect for the player itself
/// (`LivingEntity.forceAddEffect`): the effect replaces any of its kind.
/// Other entities' effects are not shown.
fn remote_effect(&mut self, p: &play::UpdateMobEffect) {
if self.remote.as_ref().is_none_or(|r| r.entity_id != p.id) {
return;
}
let Some(id) = self.remote_effect_id(p.effect) else {
return;
};
let mut e = horizon_client::effects::EffectInstance::new(id, p.duration, p.amplifier);
e.ambient = p.flags & 1 != 0;
e.visible = p.flags & 2 != 0;
let list = &mut self.potions.effects.list;
match list.iter_mut().find(|x| x.id == id) {
// forceAddEffect's copyBlendState: an updated effect keeps its fade.
Some(existing) => *existing = e,
None => {
// ClientboundUpdateMobEffectPacket.shouldBlend (flag 8).
let blend = p.flags & 8 != 0;
if id == "darkness" {
self.sculk.darkness = super::sculk::darkness_blend_added(&e, blend);
} else if id == "nausea" {
let (o_nausea, nausea) = screen_effects::nausea_blend_added(&e, blend);
self.screen_fx.o_nausea = o_nausea;
self.screen_fx.nausea = nausea;
}
list.push(e);
}
}
}
fn item_id(&self, name: &str) -> i32 {
let r = self.remote.as_ref().expect("remote");
r.builtin
.get("minecraft:item")
.and_then(|i| i.id(name))
.unwrap_or(0)
}
/// A stack from the server, decoded as the saved and command item NBT
/// are, so tooltips and models see every field.
pub(super) fn to_stack(&self, s: &Option<Stack>) -> Option<ItemStack> {
let r = self.remote.as_ref()?;
let stack = s.as_ref()?;
if !horizon_protocol::item::custom::permitted(stack, r.registries.custom_components)
.unwrap_or(false)
{
log::warn!("Dropping a stack with unnegotiated custom components");
return None;
}
Some(horizon_client::item_sync::from_network(
stack,
&r.builtin,
&r.registries,
))
}
/// A stack to the server (`SetCreativeModeSlot`): the same component
/// patch a Horizon server sends its clients, with the registries' ids, so
/// the server's echo doesn't hand back a bare item.
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
let r = self.remote.as_ref()?;
// A stack as the server sent it, only its count changed: its own
// components go back, as a vanilla client keeps them.
if let Some((stack, patch)) = s.as_ref().and_then(|s| Some((s, s.wire_patch()?))) {
let mut out = Stack::new(self.item_id(&stack.item), stack.count as i32);
let safe = r.registries.custom_components
|| horizon_protocol::item::custom::entries().next().is_none()
|| horizon_protocol::item::WirePatch::decode(&patch)
.and_then(|(added, removed)| {
let decoded = Stack {
added,
removed,
..Stack::new(out.item, out.count)
};
horizon_protocol::item::custom::permitted(&decoded, false)
})
.unwrap_or(false);
if safe {
out.wire = horizon_protocol::item::WirePatch(Some(patch));
return Some(out);
}
log::warn!("Cannot send cached custom components to this peer");
}
horizon_client::lan::guest::stack_to_network(
|name| self.item_id(name),
// Built-in first, as a hub's `SyncData::id` does: potions are one.
|registry, name| {
r.builtin
.get(registry)
.and_then(|ids| ids.id(name))
.or_else(|| r.registries.id(registry, name))
.or_else(|| {
(r.registries.custom_components
&& registry == "minecraft:data_component_type")
.then(|| horizon_protocol::item::custom::id(name))
.flatten()
})
},
s,
)
}
/// `HashedStack.create` with the connection's registries (the client's
/// `decoratedHashOpsGenerator`).
fn hashed_stack(&self, s: &Option<ItemStack>) -> Option<Hashed> {
let r = self.remote.as_ref()?;
let names = SyncedNames {
builtin: &r.builtin,
dynamic: &r.registries,
};
self.to_network(s).map(|s| Hashed::create(&s, &names))
}
/// A line in the connection's chat, with its styles, click and hover
/// events (ChatComponent.addMessage). The lines the Chat option hides
/// are dropped there (its `visibleMessageFilter`).
pub(super) fn chat_line(
&mut self,
content: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
) -> bool {
self.signed_chat_line(content, source, None, ChatTrust::Secure)
}
/// `chat_line` of a player message, keeping its signature for a
/// deletion (`GuiMessage.signature`).
fn signed_chat_line(
&mut self,
content: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
trust: ChatTrust,
) -> bool {
// ChatListener.showMessageToPlayer: Only Show Secure Chat drops what is not secure.
if matches!(trust, ChatTrust::NotSecure) && self.settings.file.bool("onlyShowSecureChat") {
return false;
}
if !self.ui.chat.shows(source) {
return false;
}
let text = content.plain();
let focused = self.screen == Screen::Chat;
if source == horizon_client::chat::ChatSource::Player {
self.ui.chat.add_signed(
&self.gui.atlas,
content,
signature,
trust.tag(),
self.ticks,
focused,
);
} else {
// addServerSystemMessage and addClientSystemMessage both carry
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
let indicator = Some(horizon_client::chat::SYSTEM_INDICATOR);
self.ui.chat.add(
&self.gui.atlas,
content,
indicator,
source,
self.ticks,
focused,
);
}
if let Some(r) = self.remote.as_mut() {
r.chat.push((text, Instant::now()));
r.chat_received += 1;
if r.chat.len() > 100 {
r.chat.remove(0);
}
}
true
}
/// `ChatListener.handleMessage` for player and disguised chat: held in the
/// queue while Chat Delay is set, shown at once otherwise. `narration` is
/// the chat type's narration of it (`decorateNarration`).
pub(super) fn handle_player_chat(
&mut self,
line: horizon_client::text::Text,
signature: Option<Signature>,
trust: ChatTrust,
narration: String,
) {
let source = horizon_client::chat::ChatSource::Player;
if self.chat_delay_ms() > 0 {
if let Some(r) = self.remote.as_mut() {
r.chat_queue
.push_back((line, source, signature, trust, narration));
}
} else {
self.accept_chat(line, source, signature, trust, &narration);
}
}
/// `ChatListener.handleChatMessageError`: a message that fails validation shows as the red,
/// italic `chat.validation_error` in its chat type (queued under Chat Delay as any
/// message). Its signature counts as processed but not shown, for the acknowledgement the
/// client sends the server. Vanilla also
/// skips a blocked sender and one restricted to friends (not kept here either) and
/// narrates the line.
fn handle_chat_message_error(
&mut self,
chat_type: &play::ChatTypeBound,
signature: Option<&Signature>,
) {
if let Some(signature) = signature {
self.mark_message_processed(signature, false);
}
let error = serde_json::json!({
"translate": "chat.validation_error",
"color": "red",
"italic": true,
});
let line = self.decorate_chat(chat_type, &error, Decoration::Chat);
// saySystemChatQueued(boundChatType.decorateNarration(CHAT_VALIDATION_ERROR)).
let narration = self
.decorate_chat(chat_type, &error, Decoration::Narration)
.plain();
self.handle_player_chat(line, None, ChatTrust::Error, narration);
}
/// `ChatTrustLevel.evaluate` of a player message, with
/// `ChatListener.evaluateTrustLevel`: the player's own message on their
/// own server is secure. `decorated` is the line shown, `unsigned` the
/// server's replacement for the signed content.
fn chat_trust(
&self,
sender: u128,
signed: bool,
timestamp: i64,
content: &str,
unsigned: Option<&Tag>,
decorated: &horizon_client::text::Text,
) -> ChatTrust {
use horizon_client::scoreboard::{comp_text, tag_to_json};
if self.integrated.is_some() && self.remote.as_ref().is_some_and(|r| r.profile == sender) {
return ChatTrust::Secure;
}
// PlayerChatMessage.hasExpiredClient: MESSAGE_EXPIRES_AFTER_CLIENT is
// the server's five minutes and two more.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
let expired = now
> timestamp.saturating_add(
horizon_protocol::secure_chat::EXPIRES_AFTER_SERVER + 2 * 60 * 1000,
);
if !signed || expired {
return ChatTrust::NotSecure;
}
// ChatTrustLevel.isModified: the signed content is not in the line, or
// the replacement uses a font other than the default.
let font_changed = unsigned.is_some_and(|tag| {
comp_text(&tag_to_json(tag)).spans.iter().any(|span| {
span.style
.font
.as_deref()
.is_some_and(|font| font != "minecraft:default")
})
});
if !decorated.plain().contains(content) || font_changed {
ChatTrust::Modified(content.to_owned())
} else {
ChatTrust::Secure
}
}
/// `ClientPacketListener.handleDeleteChat`: the packed signature names a
/// message; one still held back by Chat Delay never shows
/// (`ChatListener.removeFromDelayedMessageQueue`), a shown one is
/// replaced (`ChatComponent.deleteMessage`). A signature the cache
/// doesn't hold is an invalid packet.
fn handle_delete_chat(&mut self, packed: &(i32, Option<Vec<u8>>)) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let Some(signature) = r.signatures.unpack(packed) else {
let reason =
horizon_client::text::Text::translate("multiplayer.disconnect.invalid_packet", &[]);
return self.leave_server(Some(reason.plain()));
};
r.secure_chat.ignore_pending(&signature);
let queued = r.chat_queue.len();
r.chat_queue
.retain(|(_, _, s, _, _)| s.as_ref() != Some(&signature));
if r.chat_queue.len() == queued {
self.ui
.chat
.delete_message(&self.gui.atlas, &signature, self.ticks);
}
Ok(())
}
/// A message's `accept`: shown, it is narrated (`narrateChatMessage`) and
/// starts the Chat Delay wait again.
fn accept_chat(
&mut self,
line: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
trust: ChatTrust,
narration: &str,
) -> bool {
let shown = self.player_chat_line(line, source, signature, trust, narration);
if let Some(signature) = &signature {
self.mark_message_processed(signature, shown);
}
if shown && let Some(r) = self.remote.as_mut() {
r.chat_previous = Some(Instant::now());
}
shown
}
/// `showMessageToPlayer`: the line joins the chat if the Chat option and
/// Only Show Secure Chat let it through, and the narrator says it
/// (`sayChatQueued`).
fn player_chat_line(
&mut self,
line: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
trust: ChatTrust,
narration: &str,
) -> bool {
// handleChatMessageError says its line as system chat.
let error = matches!(trust, ChatTrust::Error);
let shown = self.signed_chat_line(line, source, signature, trust);
if shown && error {
self.say_system_chat_queued(&horizon_client::text::Text::literal(narration));
} else if shown {
self.say_chat_queued(narration);
}
shown
}
/// `ChatListener.messageDelay`: Chat Delay in milliseconds, as Java
/// truncates it.
fn chat_delay_ms(&self) -> i64 {
(self.settings.chat_delay * 1000.0) as i64
}
/// `ChatListener.tick`: while paused the wait does not run down; with no
/// Chat Delay the queue flushes; otherwise the next message the chat takes
/// shows once the delay has passed since the last shown one.
pub(super) fn tick_chat_queue(&mut self) {
let delay = self.chat_delay_ms();
// Minecraft.isPaused: a single-player pausing screen, not every pause menu.
if self.pause {
if delay > 0
&& let Some(r) = self.remote.as_mut()
{
r.chat_previous = r.chat_previous.map(|t| t + Duration::from_millis(50));
}
return;
}
if delay == 0 {
if self
.remote
.as_ref()
.is_some_and(|r| !r.chat_queue.is_empty())
{
self.flush_chat_queue();
}
return;
}
let ready = self.remote.as_ref().is_some_and(|r| {
r.chat_previous
.is_none_or(|t| t.elapsed() >= Duration::from_millis(delay as u64))
});
if !ready {
return;
}
// A message the Chat option hides is dropped and the next one is tried,
// until one shows.
while let Some((line, source, signature, trust, narration)) =
self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
{
if self.accept_chat(line, source, signature, trust, &narration) {
break;
}
}
}
/// `ChatListener.flushQueue`: every held-back message shows now, and the
/// delay starts over.
fn flush_chat_queue(&mut self) {
let Some(queue) = self
.remote
.as_mut()
.map(|r| std::mem::take(&mut r.chat_queue))
else {
return;
};
for (line, source, signature, trust, narration) in queue {
self.accept_chat(line, source, signature, trust, &narration);
}
if let Some(r) = self.remote.as_mut() {
r.chat_previous = None;
}
}
/// The minecarts the server sent, for the control socket's `status`
/// (their velocity is not tracked here).
pub(super) fn remote_carts(&self) -> Option<Vec<serde_json::Value>> {
let r = self.remote.as_ref()?;
let mut carts: Vec<&Tracked> = r
.entities
.values()
.filter(|t| t.kind.ends_with("minecart"))
.collect();
carts.sort_by(|a, b| {
a.position
.partial_cmp(&b.position)
.unwrap_or(std::cmp::Ordering::Equal)
});
Some(
carts
.into_iter()
.map(|t| serde_json::json!({"kind": t.kind, "position": t.position, "velocity": [0.0, 0.0, 0.0]}))
.collect(),
)
}
/// `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()?;
let mut names: Vec<String> = r.players.values().cloned().collect();
names.sort();
Some(names)
}
/// The open menu's window id (0: the inventory).
pub(super) fn remote_menu_id(&self) -> i32 {
self.remote
.as_ref()
.and_then(|r| r.menu.as_ref())
.map_or(0, |m| m.0)
}
/// The server's last `n` chat lines, newest first.
pub(super) fn remote_chat(&self, n: usize) -> Option<Vec<String>> {
let r = self.remote.as_ref()?;
Some(r.chat.iter().rev().take(n).map(|l| l.0.clone()).collect())
}
/// Received events left for the next frame (`poll_remote`): until they
/// are handled, the terrain the server sent is not all there.
pub(super) fn remote_backlog(&self) -> usize {
self.remote.as_ref().map_or(0, |r| r.backlog.len())
}
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(super) fn poll_remote(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
while let Some(item) = events.pop_front() {
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none.
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
/// ClientSuggestionProvider.customSuggestion: the chat's completion
/// request for an `ask_server` node goes to the server.
fn send_suggestion_request(&mut self) {
if self.remote.is_none() {
return;
}
let Some((id, command)) = self
.ui
.chat_screen
.as_mut()
.and_then(|s| s.take_request())
.or_else(|| self.command_block_suggestion_request())
else {
return;
};
let mut w = horizon_protocol::buf::Writer::new();
w.varint(id);
w.string(&command);
self.send(S::Unknown(
horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION,
w.data,
));
}
/// ClientboundCommandSuggestionsPacket: id, range start and length, then
/// each suggestion's text and optional tooltip.
fn handle_command_suggestions(&mut self, data: &[u8]) {
let lang = self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default();
let mut r = horizon_protocol::buf::Reader::new(data);
let decoded = (|| {
let id = r.varint()?;
let start = r.varint()?;
let length = r.varint()?;
let n = r.count(usize::MAX)?;
let mut list = Vec::with_capacity(n.min(1000));
for _ in 0..n {
let text = r.string(32767)?;
let tooltip = if r.bool()? {
Some(plain(&r.nbt()?, &lang))
} else {
None
};
list.push((text, tooltip));
}
horizon_protocol::Result::Ok((id, start, length, list))
})();
if let Ok((id, start, length, list)) = decoded {
let range = (start.max(0) as usize, length.max(0) as usize);
self.chat_server_suggestions(id, range, list.clone());
self.command_block_server_suggestions(id, range, list);
}
}
fn set_registries(&mut self, registries: Arc<Dynamic>) {
let biomes: Vec<u16> = registries
.registries
.get("minecraft:worldgen/biome")
.map(|list| {
list.iter()
.map(|(name, _)| world_biomes!(self).id(name))
.collect()
})
.unwrap_or_default();
if let Some(r) = self.remote.as_mut() {
let bits = ceil_log2(biomes.len());
let column = r.chunk_format.layout();
r.chunk_format =
Arc::new(ChunkFormat::new(&r.dimension, biomes, bits).in_column(column));
r.registries = registries;
}
}
/// Switch to a (possibly new) dimension: a fresh client level.
fn enter_remote_dimension(
&mut self,
spawn: &play::SpawnInfo,
keep_world: bool,
reason: horizon_client::level_loading::Reason,
) {
// The replacement player starts with its inventory menu, even if the
// server switched levels without closing the previous container.
self.remote_close_local();
self.screen = Screen::Playing;
let Some(r) = self.remote.as_mut() else {
return;
};
let dimension = r.registries.dimension(spawn.dimension_type);
// The server's dimension type gives the level's shape, and its
// chunks the shape's column.
let shape = horizon_client::chunk::LevelShape::new(
dimension.min_y,
dimension.height,
dimension.logical_height,
dimension.has_skylight,
)
.unwrap_or_else(|_| super::Dim::from_id(&dimension.name).shape());
// handleRespawn keeps the ClientLevel only for the same level key:
// two levels sharing a dimension type are still two levels.
let same = keep_world && r.heard_level.as_deref() == Some(spawn.dimension.as_str());
r.chunk_format = Arc::new(
r.chunk_format
.with_dimension(&dimension)
.in_column(shape.column()),
);
r.dimension = dimension;
r.game_mode = spawn.game_type;
r.flat = spawn.flat;
r.loaded_sent = false;
// A replacement LevelLoadingScreen starts its bar from nothing (the
// first one carries on from the server's start).
if self.player.is_some() {
self.loading_progress = 0.0;
}
// startWaitingForNewLevel: `LevelLoadTracker.startClientLoad` and the
// `LevelLoadingScreen`.
r.level_load.start_client_load(client_millis(), reason);
// A replacement LocalPlayer has a fresh sendPosition baseline.
r.position_sender = Default::default();
r.predictions.clear();
self.set_mode_id(spawn.game_type);
self.dim = Dim::from_id(&spawn.dimension);
self.audio.set_world(&spawn.dimension);
// ClientPacketListener.handleLogin / handleRespawn: the server's
// Player.getLastDeathLocation, for the recovery compass.
self.level.last_death = spawn
.last_death
.as_ref()
.map(|d| (d.dimension.clone(), d.pos));
if !same {
// A new ClientLevel: Minecraft.setLevel stops every sound.
self.stop_sounds_for_new_level();
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
self.pack.context.clone(),
self.res.threads(),
);
streaming.remote = true;
streaming.radius = self.streaming.radius;
self.streaming = streaming;
let (time, day, weather) = (
self.world.game_time,
self.world.day_time,
self.world.weather,
);
let old = std::mem::take(&mut self.world.chunks);
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;
self.mobs.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
self.dropped.clear();
self.particles.clear();
self.pickups.clear();
if let Some(r) = self.remote.as_mut() {
r.entities.clear();
r.lerps.clear();
r.cart_lerps.clear();
r.stepped.clear();
r.head_lerps.clear();
r.pending_items.clear();
// The entity ids are the old world's; the new dimension's
// player entities re-map to their profiles on AddEntity.
r.level_players.clear();
r.level_player_names.clear();
r.level_player_textures.clear();
// A new ClientLevel: a new chunk storage, centred on 0, 0.
r.chunk_center = (0, 0);
}
self.clear_lod();
self.pending_uploads.clear();
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections(self.world.layout());
}
// The new ClientLevel is told before setLevel drops the old one.
let old = self
.remote
.as_mut()
.and_then(|r| r.heard_level.replace(spawn.dimension.clone()));
self.native_client_level_load(&spawn.dimension);
if let Some(old) = old {
self.native_client_level_unload(&old);
}
}
let has_sky = self
.remote
.as_ref()
.is_none_or(|r| r.dimension.has_skylight);
self.streaming.sky_light = has_sky;
self.streaming.nether_shading = self.dim == Dim::Nether;
// The seed in SpawnInfo is already the hashed one the client's BiomeManager holds.
self.streaming.biome_zoom = horizon_core::biome::BiomeZoom::new(spawn.seed);
self.player = None;
// handleLogin / handleRespawn: LocalPlayer.setPortalCooldown, so a
// player who arrives standing in a portal gets no overlay from it.
self.portals.player.cooldown = spawn.portal_cooldown;
self.apply_settings();
}
/// `ClientChunkCache.Storage.inRange`: within the storage around the
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
self.remote.as_ref().is_none_or(|r| {
let range = r.view_distance.max(2) + 3;
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
})
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
.world
.chunks
.keys()
.copied()
.filter(|&(x, z)| !self.remote_chunk_in_range(x, z))
.collect();
if gone.is_empty() {
return;
}
let mut freed = Vec::with_capacity(gone.len());
for &(x, z) in &gone {
freed.extend(self.streaming.forget(&mut self.world, x, z));
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns(gone.iter().copied());
}
horizon_client::chunk::free_columns(freed, gone.len());
// One pass for all of them.
self.world
.block_entities
.retain(|k, _| !gone.contains(&(k.0 >> 4, k.2 >> 4)));
}
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
/// packet's type, loads the tag (`loadWithComponents`); an empty tag
/// changes nothing.
pub(super) fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) {
let key = (pos[0], pos[1], pos[2]);
let Some(name) = self
.remote
.as_ref()
.and_then(|r| r.builtin.get("minecraft:block_entity_type")?.name(kind))
else {
return;
};
// A decorated pot is no block entity here: its tag is kept with the chunk.
if name == "minecraft:decorated_pot" {
if let Tag::Compound(fields) = tag
&& !fields.is_empty()
{
let mut full = fields.clone();
full.insert("id".into(), Tag::String(name.into()));
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
full.insert(k.into(), Tag::Int(v));
}
super::spill::put_saved_entity(&mut self.world, pos, Tag::Compound(full));
// The section is drawn again with the pot's sherds.
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
}
return;
}
let Tag::Compound(fields) = tag else {
return;
};
// A mod block entity's type is named after its block (`mod_content`):
// its update tag merges into what the client keeps of it (`handleUpdateTag`).
// Nothing is drawn from it yet, so its section stays as it is.
let block = self.world.block(pos[0], pos[1], pos[2]);
if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&key) {
if *self.res.facts().get(block).name == *name {
m.merge_update_tag(fields);
}
return;
}
let Some(entity) = self.world.block_entities.get(&key) else {
return;
};
if entity.id() != name || fields.is_empty() {
return;
}
let mut full = fields.clone();
full.insert("id".into(), Tag::String(name.into()));
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
full.insert(k.into(), Tag::Int(v));
}
if let Some((_, mut loaded)) =
horizon_client::save::decode_block_entity(&Tag::Compound(full))
{
keep_client_state(self.world.block_entities.get(&key), &mut loaded);
let command_block = matches!(loaded, BlockEntity::CommandBlock(_));
self.world.block_entities.insert(key, loaded);
self.update_spawner_display(key);
// The section is drawn again with what the block entity shows.
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
if command_block {
self.command_block_data_arrived(key);
}
}
}
/// `handleAnimate`: a player wakes up, or an entity gets the critical
/// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter.
fn remote_animate(&mut self, id: i32, action: u8) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let body = if me {
self.player.as_ref().map(|p| {
let d = p.dimensions;
(
[p.position.x, p.position.y, p.position.z],
f64::from(d.width),
f64::from(d.height),
)
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
(
[m.position.x, m.position.y, m.position.z],
m.kind.width,
m.kind.height,
)
})
};
let Some((feet, width, height)) = body else {
return;
};
let particle = match action {
// Player.stopSleepInBed.
0 => {
if me {
self.sleeping = None;
self.sleep_timer = 100;
}
return;
}
1 => ParticleType::Crit,
2 => ParticleType::EnchantedHit,
_ => return,
};
self.particles
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
}
/// `handleServerData`: the server list entry of the server joined keeps
/// the icon the server sends (`ServerList.saveSingleServer`). Single
/// player has no entry.
fn remote_server_data(&mut self, icon: Option<&[u8]>) {
let (Some(r), Some(png)) = (self.remote.as_ref(), icon) else {
return;
};
if self.integrated.is_some() {
return;
}
if let Ok(dir) = std::env::current_dir() {
horizon_client::multiplayer::save_server_icon(
&dir.join("saves"),
&r.session.address,
png,
);
}
}
/// Keep the renderable block entities (chests, furnaces) in step with blocks.
/// `previous`: the block's state before, when it changed.
pub(super) fn sync_block_entity(
&mut self,
pos: (i32, i32, i32),
previous: Option<BlockStateId>,
state: BlockStateId,
) {
let name = self.res.facts().get(state).name.clone();
// LevelChunk.setBlockState: another block's block entity goes, even
// one of the same type (a mod's blocks may share one).
let other_block = previous.is_some_and(|p| self.res.facts().get(p).name != name);
// A mod block's is the mod's own (`native_block_entity`), as on the server.
match self.new_block_entity(&name) {
Some(entity) => {
let same = self.world.block_entities.get(&pos).is_some_and(|e| {
e.id() == entity.id() && !(other_block && matches!(e, BlockEntity::Mod(_)))
});
if !same {
self.world.block_entities.insert(pos, entity);
}
}
None => {
self.world.block_entities.remove(&pos);
}
}
}
/// ClientLevel.setServerVerifiedBlockState: a block the client predicted keeps its
/// predicted state until the server acknowledges the action; the server's state is
/// remembered for then.
fn server_block(&mut self, pos: [i32; 3], state: i32) {
if let Some((_, known)) = self
.remote
.as_mut()
.and_then(|r| r.predictions.get_mut(&pos))
{
*known = state;
return;
}
self.set_block(pos, state);
}
/// BlockStatePredictionHandler.endPredictionsUpTo (ClientboundBlockChangedAckPacket): the
/// actions up to `sequence` are settled, so the blocks they predicted take the state the
/// server gave them (a break the server refused comes back).
fn end_predictions(&mut self, sequence: i32) {
let Some(r) = self.remote.as_mut() else {
return;
};
let settled: Vec<_> = r
.predictions
.iter()
.filter(|(_, (s, _))| *s <= sequence)
.map(|(&pos, &(_, state))| (pos, state))
.collect();
for (pos, state) in settled {
if let Some(r) = self.remote.as_mut() {
r.predictions.remove(&pos);
}
if self.world.block(pos[0], pos[1], pos[2]).0 as i32 != state {
self.set_block(pos, state);
}
}
}
fn set_block(&mut self, pos: [i32; 3], state: i32) {
let state = BlockStateId(state.max(0) as u32);
let previous = self.world.block(pos[0], pos[1], pos[2]);
if self.world.loaded(pos[0], pos[2])
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok()
{
self.sync_block_entity((pos[0], pos[1], pos[2]), Some(previous), state);
// LevelChunk.setBlockState removes the block entity of a block
// that changes: the moving piston the client's moveBlocks made
// stops when the server places what it moved (#3002).
let facts = self.res.facts();
let moving = |s: BlockStateId| &*facts.get(s).name == "minecraft:moving_piston";
if moving(previous) && !moving(state) {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
self.block_ticks.moving_pistons.retain(|(p, _)| *p != at);
self.sync_moving_pistons();
}
}
// A block we just placed: play its place sound (the server skips the placer).
if let Some((target, _)) = self.remote.as_ref().and_then(|r| r.placing)
&& target == pos
&& !self.res.facts().is_air(state)
{
let s = self.res.facts().get(state).sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio
.play(&s.place, Some(at), (s.volume + 1.0) / 2.0, s.pitch * 0.8);
if let Some(r) = self.remote.as_mut() {
r.placing = None;
}
}
}
/// `recreateFromPacket`: yRot and xRot from the packet, the body and head
/// both at its head yaw (LivingEntity).
fn spawn_entity(&mut self, id: i32, kind: &str, position: [f64; 3], rotation: [f32; 3]) {
let [yaw, pitch, head] = rotation;
let at = Vec3::new(position[0], position[1], position[2]);
let mob_type = if kind == "minecraft:player" {
self.remote.as_ref().and_then(|r| r.player_type.clone())
} else {
self.res.mob_types().get(kind).cloned()
};
if kind == "minecraft:ender_dragon" {
self.spawn_remote_dragon(id, position, yaw);
}
if kind == "minecraft:experience_orb" {
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
self.orbs.retain(|o| o.id != id as u32);
self.orbs.push(ExperienceOrb {
no_gravity: false,
id: id as u32,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
});
}
if let Some(t) = mob_type {
let mut mob = Mob::new(id as u32, t, at, head);
super::remote_mob_data::client_defaults(&mut mob);
(mob.yaw, mob.previous_yaw) = (yaw, yaw);
(mob.head_pitch, mob.previous_head_pitch) = (pitch, pitch);
mob.persistent = true;
self.mobs.retain(|m| m.id != id as u32);
self.mobs.push(mob);
}
if let Some(r) = self.remote.as_mut() {
// A new entity has a new interpolation handler (a mob's or an
// armor stand's), even under an id the server used before.
r.stepped.remove(&id);
if kind == "minecraft:item" {
r.pending_items.insert(id, (position, Vec3::ZERO));
}
r.entities.insert(
id,
Tracked {
kind: kind.to_owned(),
position,
},
);
}
}
/// A move packet for entity `id`: to the end of `path` (None: a
/// rotation alone), turned to `rotation` if it has one. `packet` says
/// whether a living entity more than 64 blocks away is put there at once
/// (its `snaps`).
fn move_entity(
&mut self,
id: i32,
path: Option<Path>,
rotation: Option<(f32, f32)>,
on_ground: bool,
packet: MovePacket,
) {
// The dragon is drawn from its own state (game/dragon.rs), which
// tick_remote_dragon eases to the server's moves through `lerps`. Its
// 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()
.find(|m| m.id == id as u32)
.filter(|_| !dragon);
let tracked = self
.remote
.as_ref()
.and_then(|r| r.entities.get(&id))
.map(|t| t.position);
let position = path
.as_ref()
.map(Path::end)
.or(tracked)
.or_else(|| {
mob.as_ref()
.map(|m| [m.position.x, m.position.y, m.position.z])
})
.unwrap_or([0.0; 3]);
if let Some(r) = self.remote.as_mut() {
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.
let entity = PosRot {
position: [m.position.x, m.position.y, m.position.z],
yaw: m.yaw,
pitch: m.head_pitch,
};
let handler = r.stepped.entry(id).or_insert_with(|| {
let interval = horizon_client::lan::tracking::settings(&m.kind.id).1;
SteppedInterpolation::new(interval as i32)
});
if let Some(at) = stepped_move(handler, packet, entity, position, path, rotation) {
// snapTo: setOldPosAndRot too.
place_interpolated(m, at);
m.previous = m.position;
m.previous_yaw = m.yaw;
m.previous_head_pitch = m.head_pitch;
}
m.on_ground = on_ground;
return;
}
// An armor stand is a living entity too: the same handler, over
// its type's update interval, stepped in `tick_remote_entities`.
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id as u32) {
let entity = PosRot {
position: [a.position.x, a.position.y, a.position.z],
yaw: a.yaw,
pitch: 0.0,
};
let handler = r.stepped.entry(id).or_insert_with(|| {
let interval =
horizon_client::lan::tracking::settings("minecraft:armor_stand").1;
SteppedInterpolation::new(interval as i32)
});
if let Some(at) = stepped_move(handler, packet, entity, position, path, rotation) {
a.snap(
Vec3::new(at.position[0], at.position[1], at.position[2]),
at.yaw,
);
}
a.on_ground = on_ground;
return;
}
// Interaction extends Entity and keeps its NO_OP interpolation
// handler: the server's position replaces its own at once.
if let Some(i) = self.interactions.get_mut(&id) {
if path.is_some() {
i.position = Vec3::new(position[0], position[1], position[2]);
}
return;
}
// Projectiles keep Entity's NO_OP InterpolationHandler too: the
// server's position replaces theirs at once and their own tick
// flies them on (`tick_remote_projectiles`). Eased towards it
// instead, they only moved when an update came, every 10 to 20
// ticks.
if let Some(p) = self
.creatures
.projectiles
.iter_mut()
.find(|p| p.id == id as u32)
{
if path.is_some() {
p.position = Vec3::new(position[0], position[1], position[2]);
}
if let Some((yaw, pitch)) = rotation {
p.yaw = yaw;
p.pitch = pitch;
}
return;
}
if path.is_some()
&& (self.potions.move_remote_thrown(
id as u32,
Vec3::new(position[0], position[1], position[2]),
) || self.end.move_remote_eye(id as u32, position))
{
return;
}
// A fishing hook keeps a LinearInterpolationHandler: eased towards the
// update over three ticks, on top of its own movement (`tick_remote_hooks`).
if path.is_some() {
if let Some(h) = self.hook.as_mut().filter(|h| h.id() == id as u32) {
h.move_to(position, on_ground);
return;
}
if let Some(h) = self.remote_hooks.iter_mut().find(|h| h.id() == id as u32) {
h.move_to(position, on_ground);
return;
}
}
// A thrown trident is an arrow: placed at once, flown on by its tick.
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
if path.is_some() {
t.position = Vec3::new(position[0], position[1], position[2]);
}
if let Some((yaw, pitch)) = rotation {
t.yaw = yaw;
t.pitch = pitch;
}
return;
}
// So does a firework rocket: its own tick flies it on.
if path.is_some()
&& let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32)
{
f.position = Vec3::new(position[0], position[1], position[2]);
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
if path.is_some() {
b.position = Vec3::new(position[0], position[1], position[2]);
}
return;
}
if let Some(p) = r.pending_items.get_mut(&id) {
p.0 = position;
}
// ItemEntity, FallingBlockEntity and PrimedTnt keep Entity's
// NO_OP InterpolationHandler: moveOrInterpolateTo puts them there
// at once, and their own tick moves them on between the server's
// updates (`tick_remote_entities`, `tick_remote_projectiles`).
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.position = at;
d.on_ground = on_ground;
return;
}
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.position = at;
return;
}
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.position = at;
t.on_ground = on_ground;
return;
}
// ExperienceOrb.createInterpolationHandler is a
// LinearInterpolationHandler: the correction is spread over 3
// ticks (`tick_remote_entities`), while `ExperienceOrb.tick` keeps
// moving it.
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id as u32) {
o.on_ground = on_ground;
r.lerps.insert(
id,
Lerp {
target: position,
rotation: None,
steps: 3,
},
);
return;
}
if r.cart_lerps.contains_key(&id) {
// A minecart on NewMinecartBehavior keeps Entity's NO_OP
// InterpolationHandler: a teleport puts it there at once.
if path.is_some()
&& let Some(c) = self.carts.iter_mut().find(|c| c.id == id as u32)
{
c.position = at;
}
} else {
// AbstractInterpolationHandler.interpolateTo: without a
// rotation, the one it was heading for.
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
r.lerps.insert(
id,
Lerp {
target: position,
rotation,
steps: 3,
},
);
}
}
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
self.move_remote_text_display(id, position, rotation);
self.turn_remote_projectile(id, rotation);
}
fn remove_entity(&mut self, id: i32) {
// Entity.setRemoved detaches every passenger, even when no empty
// SetPassengers packet preceded the vehicle's removal. Otherwise
// mobs and other players keep their passenger pose after it is gone.
self.remote_set_passengers(id, &[]);
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
// Before the crystals go: the dragon's beam index follows them.
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.lerps.remove(&id);
r.cart_lerps.remove(&id);
r.stepped.remove(&id);
r.head_lerps.remove(&id);
r.pending_items.remove(&id);
}
}
/// Map a menu slot to Horizon's inventory screen slot ids.
fn menu_slot(menu: &str, slot: i32) -> Option<usize> {
let s = usize::try_from(slot).ok()?;
// A container of `n` slots, then the inventory and the hotbar.
let slots = |n: usize| -> Option<usize> {
match s {
_ if s < n => Some(300 + s),
_ if s < n + 27 => Some(9 + s - n),
_ if s < n + 36 => Some(s - n - 27),
_ => None,
}
};
let rows = |n: usize| slots(n * 9);
match menu {
"inventory" => match s {
0 => Some(200),
1..=4 => Some(100 + s - 1),
5..=8 => Some(500 + s - 5),
9..=35 => Some(s),
36..=44 => Some(s - 36),
// InventoryMenu.SHIELD_SLOT: the off hand.
45 => Some(504),
_ => None,
},
"minecraft:crafting" => match s {
0 => Some(200),
1..=9 => Some(100 + s - 1),
10..=36 => Some(9 + s - 10),
37..=45 => Some(s - 37),
_ => None,
},
"minecraft:merchant" => match s {
0 | 1 => Some(super::stations::INPUT + s),
2 => Some(super::stations::RESULT),
3..=29 => Some(9 + s - 3),
30..=38 => Some(s - 30),
_ => None,
},
"minecraft:furnace" | "minecraft:blast_furnace" | "minecraft:smoker" => match s {
0..=2 => Some(300 + s),
3..=29 => Some(9 + s - 3),
30..=38 => Some(s - 30),
_ => None,
},
"minecraft:generic_9x1" => rows(1),
"minecraft:generic_9x2" => rows(2),
"minecraft:generic_9x3" | "minecraft:shulker_box" => rows(3),
"minecraft:generic_9x4" => rows(4),
"minecraft:generic_9x5" => rows(5),
"minecraft:generic_9x6" => rows(6),
// HopperMenu; DispenserMenu (dispensers and droppers).
"minecraft:hopper" => slots(5),
"minecraft:generic_3x3" => slots(9),
"minecraft:brewing_stand" => slots(5),
// CrafterMenu: the grid, the inventory, then the result (the
// client's own).
"minecraft:crafter_3x3" => slots(9),
// HorseInventoryMenu: the saddle, the body armour, the chest,
// then the inventory.
m if m.starts_with(MOUNT_MENU) => {
let n = m[MOUNT_MENU.len()..].parse::<usize>().ok()? * 3;
match s {
0 => Some(super::mount_menu::SADDLE),
1 => Some(super::mount_menu::ARMOR),
_ if s < 2 + n => Some(super::mount_menu::CHEST + s - 2),
_ if s < 2 + n + 27 => Some(9 + s - 2 - n),
_ if s < 2 + n + 36 => Some(s - 2 - n - 27),
_ => None,
}
}
// A content mod's menu type: its container, then the inventory.
// A workstation: its inputs, its result, then the inventory.
m => {
if let Some(t) = horizon_client::containers::mod_menu_type(m) {
return slots(t.slots);
}
let kind = Self::remote_station_kind(m)?;
let inputs = kind.inputs();
let own = inputs + usize::from(kind.has_result());
match s {
_ if s < inputs => Some(super::stations::INPUT + s),
_ if s < own => Some(super::stations::RESULT),
_ if s < own + 27 => Some(9 + s - own),
_ if s < own + 36 => Some(s - own - 27),
_ => None,
}
}
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
pub(super) fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
}
/// The workstation screen of a menu type a server opens.
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
use super::stations::Kind;
Some(match menu {
"minecraft:loom" => Kind::Loom,
"minecraft:stonecutter" => Kind::Stonecutter,
"minecraft:grindstone" => Kind::Grindstone,
"minecraft:anvil" => Kind::Anvil,
"minecraft:smithing" => Kind::Smithing,
"minecraft:enchantment" => Kind::Enchanting,
"minecraft:beacon" => Kind::Beacon,
"minecraft:cartography_table" => Kind::Cartography,
_ => return None,
})
}
fn open_menu_name(&self, container: i32) -> String {
match self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
Some((id, name)) if *id == container && container != 0 => name.clone(),
_ => "inventory".into(),
}
}
fn set_menu_slot(&mut self, container: i32, slot: i32, item: Option<ItemStack>) {
let menu = self.open_menu_name(container);
if menu == "minecraft:lectern" {
if slot == 0 {
self.remote_lectern_book(item);
}
return;
}
if container != 0 && menu == "inventory" {
return;
}
let Some(internal) = Self::menu_slot(&menu, slot) else {
return;
};
if internal == 200 {
return; // the result slot is recomputed from the grid
}
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);
}
}
/// `ClientPacketListener.handleUpdateAttributes` for a mob, each value from
/// `AttributeInstance.calculateValue`: its maximum health (the hearts drawn
/// over its mount), and a player's or mannequin's `minecraft:scale`, which
/// scales its model (`LivingEntityRenderState.scale`) as the local player's
/// does: NPC plugins make their fake players bigger with it.
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
let catalog = &self.motion.attributes;
let value = |name: &str| {
let attribute = catalog.id(name)?;
let s = values
.iter()
.find(|s| s.attribute == attribute.registry_id())?;
Some(
catalog
.attribute(attribute)
.definition
.sanitize(snapshot_value(s)),
)
};
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,
horizon_client::mobs::finalize::Attr::MaxHealth,
max_health,
);
}
if let Some(scale) = scale {
m.extra.model_scale = scale as f32;
}
}
fn handle_packet(&mut self, packet: C) -> Result<()> {
match packet {
// ClientCommonPacketListenerImpl.handlePing runs on the game
// thread, after all preceding packets (especially teleports).
C::Ping(p) => self.send(S::Pong(p)),
// A native mod's server half to its client half.
C::CustomPayload(p) if p.channel == horizon_protocol::mod_net::CHANNEL => {
self.remote_native_net(&p.data);
}
// The server's recipes its native mods asked to send.
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
self.remote_recipe_content(&p.data);
}
// PingDebugMonitor.onPongReceived.
C::PongResponse(p) => {
let delay = self.debug_millis() - p.time;
self.debug_charts.ping.log(delay);
}
C::Login(p) => {
self.test_highlights.clear();
self.forget_synced_recipes();
self.level.hardcore = p.hardcore;
// handleLogin: startWaitingForNewLevel with Reason.OTHER, which
// replaces ServerReconfigScreen.
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = None;
}
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0, no effects, riding nothing: the server
// pairs a vehicle again with ClientboundSetPassengersPacket).
self.selected = 0;
self.potions.effects.clear();
self.riding = None;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.own_invisible = false;
r.online_mode = p.online_mode;
r.enforces_secure_chat = p.enforces_secure_chat;
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.simulation_distance = p.simulation_distance;
r.show_death_screen = p.show_death_screen;
}
self.streaming.radius =
self.render_distance_in_use().min(p.chunk_radius + 1).max(2);
// handleLogin: startWaitingForNewLevel(.., Reason.OTHER).
self.enter_remote_dimension(
&p.spawn,
false,
horizon_client::level_loading::Reason::Other,
);
let info = self
.settings
.client_information(client_view_distance(self.render_distance_in_use()));
if let Some(r) = self.remote.as_mut() {
r.sent_info = info.clone();
}
self.send(S::ClientInformation(info));
self.remote_chat_login();
}
C::Respawn(p) => {
// A new player, without effects (the server sends them
// again) and with no item in use.
self.potions.effects.clear();
self.using = None;
self.end_mod_use();
// After a death (no KEEP_ENTITY_DATA) the new LocalPlayer
// rides nothing: the server pairs a vehicle again with its
// 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);
let died = self.screen == Screen::Dead
|| self
.player
.as_ref()
.is_some_and(|m| m.health.health() <= 0.0);
let reason = horizon_client::level_loading::reason_for_respawn(
died,
&p.spawn.dimension,
self.dim.id(),
);
if dimension_changed {
horizon_client::spans::startup::arm("dimension.change");
}
self.enter_remote_dimension(&p.spawn, true, reason);
if dimension_changed {
// LevelExtractor.setLevel: a new level drops the highlights.
self.test_highlights.clear();
let (music, _) = self.situational_music();
self.music.stop_playing(music.as_ref(), &mut self.audio);
}
self.screen = Screen::Playing;
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);
}
C::PlayerPosition(p) => self.remote_teleport(&p)?,
C::PlayerRotation(p) => {
self.yaw = if p.relative_yaw {
self.yaw + p.yaw
} else {
p.yaw
};
self.pitch = (if p.relative_pitch {
self.pitch + p.pitch
} else {
p.pitch
})
.clamp(-90.0, 90.0);
self.snap_player_model_pitch();
}
C::PlayerLookAt(p) => self.remote_look_at(&p),
// ClientCommonPacketListenerImpl.handlePostEffects.
C::PostEffects(p) => self.post_effects = p.effects,
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::ChunksBiomes(p) => self.remote_chunks_biomes(&p),
C::ForgetLevelChunk(p) => {
let [x, z] = p.pos;
self.streaming.forget(&mut self.world, x, z);
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns([(x, z)]);
}
}
C::ChunkBatchFinished(_) => {
// ChunkBatchSizeCalculator's ceiling; meshing runs on workers.
self.send(S::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 64.0,
}));
}
C::BlockUpdate(p) => self.server_block(p.pos, p.state),
C::AddTransientBlock(p) => self.add_transient_block(p.pos, p.state),
// ClientPacketListener.handleBlockDestruction: another player's crack.
C::BlockDestruction(p) => {
self.destroy_block_progress(p.id, p.pos, i32::from(p.progress));
}
C::SectionBlocksUpdate(p) => {
let [sx, sy, sz] = p.section;
for change in p.changes {
let local = (change & 0xFFF) as i32;
let pos = [
sx * 16 + (local >> 8 & 15),
sy * 16 + (local & 15),
sz * 16 + (local >> 4 & 15),
];
self.server_block(pos, (change >> 12) as i32);
}
}
C::BlockChangedAck(p) => self.end_predictions(p.sequence),
// Level.blockEvent on the client level: the block's triggerEvent
// runs on the client's copy of whatever is at pos (a piston's
// moveBlocks builds the moving block entities it animates, a note
// block plays); what it doesn't handle reaches the client copy of
// the block entity (chest lids, shulker boxes).
C::BlockEvent(p) => {
let pos = horizon_core::position::BlockPos::new(p.pos[0], p.pos[1], p.pos[2]);
let triggered = self.with_level(|b, level, ticks| {
b.client_block_event(level, ticks, pos, i32::from(p.b0), i32::from(p.b1))
});
self.sync_moving_pistons();
self.process_level_events()?;
if !triggered {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let block = &self.res.facts().get(state).name;
let named = self.remote.as_ref().and_then(|r| {
r.builtin
.get("minecraft:block")?
.name(p.block)
.map(str::to_owned)
});
if named.as_deref() == Some(&**block) {
self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1));
}
}
}
// ClientLevel.levelEvent: LevelEventHandler, as this client's own
// level plays them (particles and sounds: a spawner's burst, bone
// meal, waxing, a block broken by someone else).
// ClientPacketListener.handleLevelEvent: a global one is heard
// from anywhere (ClientLevel.globalLevelEvent).
C::LevelEvent(p) if p.global => self.global_level_event_sound(p.kind, p.pos),
C::LevelEvent(p) => self.level_event_at(p.kind, p.pos, p.data),
C::LevelParticles(p) => self.remote_level_particles(&p),
C::Explode(p) => self.remote_explode(&p),
C::Sound(p) => {
if let Some(name) = self.remote_sound_name(&p.sound) {
let at = [
f64::from(p.x) / 8.0,
f64::from(p.y) / 8.0,
f64::from(p.z) / 8.0,
];
// ClientPacketListener.handleSoundEvent plays with packet.getSource().
match usize::try_from(p.source)
.ok()
.filter(|s| *s < horizon_client::settings::SOUND_SOURCES.len())
{
Some(source) => {
self.audio
.play_on(source, &name, Some(at), p.volume, p.pitch)
}
None => self.audio.play(&name, Some(at), p.volume, p.pitch),
}
}
}
// shortcut: vanilla's `EntityBoundSoundInstance` re-reads the entity's position
// every tick and stops with it, so the sound follows the entity; a play at the
// packet's arrival does not. Upgrade when the sound engine has a generic
// entity-bound instance (its tickable ones are one per mob kind, e.g. `Ticking::Bee`).
C::SoundEntity(p) => {
if let Some(name) = self.remote_sound_name(&p.sound)
&& let Some(at) = self.remote_entity_position(p.id)
{
match usize::try_from(p.source)
.ok()
.filter(|s| *s < horizon_client::settings::SOUND_SOURCES.len())
{
Some(source) => {
self.audio
.play_on(source, &name, Some(at), p.volume, p.pitch)
}
None => self.audio.play(&name, Some(at), p.volume, p.pitch),
}
}
}
C::StopSound(p) => self
.audio
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
C::AddEntity(p) => {
let kind = self
.remote
.as_ref()
.and_then(|r| {
r.builtin
.get("minecraft:entity_type")?
.name(p.kind)
.map(str::to_owned)
})
.unwrap_or_default();
let rotation =
[p.yaw, p.pitch, p.head_yaw].map(horizon_protocol::buf::unpack_degrees);
if let Some(r) = self.remote.as_mut() {
// A new entity looks its PlayerInfo up afresh.
r.level_player_textures.remove(&p.id);
if kind == "minecraft:player" {
r.level_players.insert(p.id, p.uuid);
// createEntityFromPacket: the player takes the profile of its list entry.
match r.players.get(&p.uuid) {
Some(name) => r.level_player_names.insert(p.id, name.clone()),
None => r.level_player_names.remove(&p.id),
};
} else {
r.level_players.remove(&p.id);
r.level_player_names.remove(&p.id);
}
}
// Entity.getTeam: a mob is on a team by its UUID, a player by
// its profile name (`Game::scoreboard_name`).
self.persist.uuids.insert(
p.id as u32,
horizon_client::lan::playerdata::uuid_ints(p.uuid),
);
self.spawn_entity(p.id, &kind, p.position, rotation);
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
self.remote_motion(p.id, p.velocity);
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
self.spawn_remote_projectile(
p.id,
&kind,
p.position,
(rotation[0], rotation[1]),
p.velocity,
p.data,
);
}
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
self.move_remote_interaction(p.id, p.position);
}
C::RemoveEntities(p) => {
for id in p.ids {
if let Some(r) = self.remote.as_mut() {
r.level_players.remove(&id);
r.level_player_names.remove(&id);
r.level_player_textures.remove(&id);
}
self.remove_entity(id);
}
}
C::MoveEntityPos(play::MoveEntityPos(m))
| C::MoveEntityPosRot(play::MoveEntityPosRot(m))
| C::MoveEntityRot(play::MoveEntityRot(m)) => {
let base = self
.remote
.as_ref()
.and_then(|r| r.entities.get(&m.id))
.map(|t| t.position);
let Some(base) = base else { return Ok(()) };
// VecDeltaCodec.decode: each step's position from the last.
let path = m.delta.as_ref().map(|d| match d {
play::VecDelta::Linear(_) => Path::Linear(d.end(base)),
play::VecDelta::Stepped(steps) => {
let mut at = base;
Path::Stepped(
steps
.iter()
.map(|(delta, ticks)| {
at = play::VecDelta::apply(at, *delta);
(at, *ticks)
})
.collect(),
)
}
});
let rotation = m.rotation.map(|(y, x)| {
(
horizon_protocol::buf::unpack_degrees(y),
horizon_protocol::buf::unpack_degrees(x),
)
});
self.move_entity(m.id, path, rotation, m.on_ground, MovePacket::Move);
}
C::EntityPositionSync(p) => {
let path = match p.path {
play::PositionPath::Linear(at) => Path::Linear(at),
play::PositionPath::Stepped(steps) => Path::Stepped(steps),
};
self.move_entity(
p.id,
Some(path),
Some((p.yaw, p.pitch)),
p.on_ground,
MovePacket::PositionSync,
)
}
C::TeleportEntity(p) => {
let base = self
.remote
.as_ref()
.and_then(|r| r.entities.get(&p.id))
.map_or([0.0; 3], |t| t.position);
let rel = |bit: i32, i: usize| {
if p.relatives & bit != 0 {
base[i] + p.position[i]
} else {
p.position[i]
}
};
let position = [
rel(play::relative::X, 0),
rel(play::relative::Y, 1),
rel(play::relative::Z, 2),
];
// setValuesFromPositionPacket: interpolated, unless too far.
self.move_entity(
p.id,
Some(Path::Linear(position)),
Some((p.yaw, p.pitch)),
p.on_ground,
MovePacket::Teleport,
);
// 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
// lead (this player, another player, a mob or a fence knot), for
// the rope.
C::SetEntityLink(p) => {
let me = self.remote.as_ref().map(|r| r.entity_id);
use horizon_client::mobs::leash::Holder;
let holder = match p.dest {
0 => None,
d if Some(d) == me => Some(Holder::Player),
d => match self.remote_knots.get(&(d as u32)) {
Some(&block) => Some(Holder::Knot(block)),
// Not a known knot yet: kept by id until the knot
// arrives (game/remote_objects.rs).
None if self.boats.iter().any(|b| b.id == d as u32) => {
Some(Holder::Boat(d as u32))
}
None => Some(Holder::Mob(d as u32)),
},
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
m.extra.leash = holder;
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == p.source as u32) {
// A leashed boat or raft (AbstractBoat is Leashable).
b.leash = holder;
}
}
C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw),
C::MoveMinecartAlongTrack(p) => self.remote_move_minecart(p),
// ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3).
C::RotateHead(p) => {
if let Some(r) = self.remote.as_mut() {
let yaw = horizon_protocol::buf::unpack_degrees(p.head_yaw);
r.head_lerps.insert(p.id, (yaw, 3));
}
}
C::SetEntityData(p) => {
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
// these instead of saved NBT.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => {
m.invisible = flags & 0x20 != 0;
m.glowing = flags & 0x40 != 0;
}
(2, Value::OptText(name)) => {
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
m.custom_name =
text.as_ref().map(horizon_client::text::Text::plain);
m.custom_name_text = text;
}
(2, Value::Text(name)) => {
let text = horizon_client::entity_nbt::name_text(name);
m.custom_name = Some(text.plain());
m.custom_name_text = Some(text);
}
(3, Value::Bool(visible)) => m.name_visible = *visible,
_ => {}
}
}
}
if let Some((_, _, Value::Byte(flags))) = p.values.iter().find(|v| v.0 == 0)
&& let Some(r) = self.remote.as_mut()
{
if flags & 0x40 != 0 {
r.glowing.insert(p.id);
} else {
r.glowing.remove(&p.id);
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
{
for (index, _, value) in &p.values {
match (index, value) {
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED, DATA_IS_IGNITED.
(16, Value::Int(dir)) => m.extra.counter = *dir,
(17, Value::Bool(powered)) => m.extra.charged = *powered,
(18, Value::Bool(ignited)) => m.extra.flag = *ignited,
_ => {}
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
(7, Value::Int(ticks)) => m.ticks_frozen = *ticks,
// Strider.DATA_SUFFOCATING (after AgeableMob's two
// and DATA_BOOST_TIME): a cold strider shakes.
(19, Value::Bool(cold))
if m.species() == horizon_client::mobs::Species::Strider =>
{
m.extra.flag = *cold;
}
_ => {}
}
}
}
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
// too, remote_mob_data's apply reads index 8 above.
let mannequin = self
.mobs
.iter()
.any(|m| m.id == p.id as u32 && &*m.kind.id == "minecraft:mannequin");
let other = mannequin
|| self.remote.as_ref().is_some_and(|r| {
r.level_players.contains_key(&p.id) && r.entity_id != p.id
});
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another
// player lies down in their bed (drawn lying in it), or gets up.
if other {
for (index, _, value) in &p.values {
if let (14, Value::OptPos(bed)) = (index, value) {
let code = bed.map_or(0, |b| self.bed_facing(b));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.extra.ints[1] = code;
if let Some(head) = *bed {
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): setPosToBed.
let at = Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1]) + 0.5625 + 0.125,
f64::from(head[2]) + 0.5,
);
m.position = at;
m.previous = at;
m.head_pitch = 0.0;
m.previous_head_pitch = 0.0;
}
}
}
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
// the player shows (PlayerModelPart bits).
if let (16, Value::Byte(bits)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
}
}
}
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
// player lies down in the bed, or gets up.
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
for (index, _, value) in &p.values {
match (index, value) {
(14, Value::OptPos(bed)) => {
if bed.is_some() && self.sleeping.is_none() {
self.sleep_timer = 0;
}
self.sleeping = *bed;
if let Some(head) = *bed {
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): setPosToBed.
let at = Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1]) + 0.5625 + 0.125,
f64::from(head[2]) + 0.5,
);
if let Some(pl) = self.player.as_mut() {
pl.position = at;
pl.velocity = Vec3::ZERO;
pl.pitch = 0.0;
}
self.previous_position = at;
self.pitch = 0.0;
self.snap_player_model_pitch();
}
}
// Entity.DATA_SHARED_FLAGS_ID's sprinting bit: the
// 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
// and golden hearts the server's player has.
(1, Value::Int(air)) => {
if let Some(pl) = self.player.as_mut() {
pl.air = *air;
}
}
// LivingEntity.DATA_ARROW_COUNT_ID and DATA_STINGER_COUNT_ID.
(12, Value::Int(count)) => {
if let Some(r) = self.remote.as_mut() {
r.own_arrows = *count;
}
}
(13, Value::Int(count)) => {
if let Some(r) = self.remote.as_mut() {
r.own_stingers = *count;
}
}
(19 | 20, Value::Id(v)) => {
if let Some(r) = self.remote.as_mut() {
r.own_shoulders[usize::from(*index == 20)] =
(*v > 0).then(|| (*v - 1).clamp(0, 4));
}
}
(17, Value::Float(amount)) => {
if let Some(pl) = self.player.as_mut() {
let _ = pl.internal_set_absorption(*amount);
}
}
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: the server
// starts or stops this player's use, as an axe that
// disables the raised shield does (Player.disableShield's
// stopUsingItem). LocalPlayer.onSyncedDataUpdated.
(8, Value::Byte(flags)) => {
let selected = self.selected;
let using = self.using;
self.using = synced_local_use(using, *flags, selected, |slot| {
self.stack_in(slot).is_some()
});
// onSyncedDataUpdated's useItemRemaining: getUseDuration,
// without startUsingItem's event.
if using.is_none()
&& let Some((slot, _)) = self.using
&& let Some(stack) = self.stack_in(slot).cloned()
{
self.use_duration = self.use_duration(&stack);
self.use_ticks_set = false;
}
}
_ => {}
}
}
}
// Entity.DATA_SHARED_FLAGS_ID's crouching and sprinting bits:
// another player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag), and the dust a runner raises.
if (mannequin
|| self
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id)))
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
m.extra.crouching = flags & 2 != 0;
m.extra.sprinting = flags & 8 != 0;
}
}
}
for (index, _, value) in &p.values {
if *index == ITEM_ENTITY_ITEM
&& let Value::Item(stack) = value
{
self.remote_item_data(p.id, stack);
}
// ExperienceOrb.DATA_VALUE (the icon's size).
if *index == 8
&& let Value::Int(v) = value
&& let Some(o) = self.orbs.iter_mut().find(|o| o.id == p.id as u32)
{
o.value = (*v).max(0) as u32;
}
}
self.remote_object_data(p.id, &p.values);
self.remote_projectile_data(p.id, &p.values);
self.remote_dragon_data(p.id, &p.values);
}
// ClientPacketListener.handleSetEquipment: what a mob or another
// player holds and wears (EquipmentSlot ordinals).
C::SetEquipment(p) => {
self.remote_object_equipment(p.id, &p.slots);
let items: Vec<(u8, Option<Arc<str>>, Option<ItemStack>)> = p
.slots
.iter()
.map(|(slot, stack)| {
(
*slot,
stack.as_ref().map(|s| self.item_name(s.item)),
self.to_stack(stack),
)
})
.collect();
// CrossbowItem.isCharged: the main hand's `charged_projectiles`
// (AvatarRenderer.getArmPose holds a loaded crossbow up).
let charged = p.slots.iter().find(|(slot, _)| *slot == 0).map(|(_, s)| {
s.as_ref().is_some_and(|s| {
matches!(
s.component(horizon_protocol::item::component::CHARGED_PROJECTILES),
Some(horizon_protocol::item::Component::Stacks(list))
if !list.is_empty()
)
})
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(charged) = charged
&& &*m.kind.id == "minecraft:player"
{
m.extra.charged = charged;
}
// The body slot (6: horse armour, llama carpet, harness)
// is kept in `head`, as Horizon's own mobs do.
let body = horizon_client::mobs::drops::head_is_body(m.species());
for (slot, item, stack) in items {
let e = &mut m.extra;
match slot {
0 => match stack {
// The components the server sent (a loaded
// crossbow's `charged_projectiles`, dyed
// leather, an enchanted book's id, ...) are
// what the item model draws.
Some(stack) => e.set_main_hand_stack(Some(stack)),
None => e.set_main_hand(item),
},
1 => e.off_hand = item,
2 => {
e.feet = item;
e.armor[3] = stack;
}
3 => {
e.legs = item;
e.armor[2] = stack;
}
4 => {
e.chest = item;
e.armor[1] = stack;
}
5 if !body => {
e.head = item;
e.armor[0] = stack;
}
6 if body => {
// A wolf's armor shows its cracks.
e.body_damage = stack.as_ref().map_or(0, |s| s.damage as i32);
e.head = item;
}
7 => m.set_saddle(stack),
_ => {}
}
}
}
}
C::TakeItemEntity(p) => {
// ClientPacketListener.handleTakeItemEntity: the pickup sound at
// the orb, item or arrow, whoever collects it (the server sends
// none); an entity this client doesn't track sounds at this player.
let orb_at = self
.orbs
.iter()
.find(|o| o.id == p.item as u32)
.map(|o| o.position);
let orb = orb_at.is_some();
let item = self.dropped.iter().find(|d| d.id == p.item as u32);
let shot = self
.creatures
.projectiles
.iter()
.find(|s| s.id == p.item as u32);
let shot = shot.map(|s| s.position).or_else(|| {
self.tridents
.iter()
.find(|t| t.id == p.item as u32)
.map(|t| t.position)
});
let at = match (item, shot) {
(Some(d), _) => Some([d.position.x, d.position.y, d.position.z]),
(None, Some(s)) => Some([s.x, s.y, s.z]),
(None, None)
if self
.remote
.as_ref()
.is_some_and(|r| r.entity_id == p.player) =>
{
self.eye_position()
}
(None, None) => None,
};
if let Some(o) = orb_at {
let pitch = (self.world.random.next_float() - self.world.random.next_float())
* 0.35
+ 0.9;
self.audio.play(
"minecraft:entity.experience_orb.pickup",
Some([o.x, o.y, o.z]),
0.1,
pitch,
);
} else if let Some(at) = at {
let pitch = ((self.world.random.next_float() - self.world.random.next_float())
* 0.7
+ 1.0)
* 2.0;
self.audio
.play("minecraft:entity.item.pickup", Some(at), 0.2, pitch);
}
// ItemPickupParticle toward the collector (this player, another one or a mob).
let collector = if self
.remote
.as_ref()
.is_some_and(|r| r.entity_id == p.player)
{
super::pickups::Collector::Player
} else {
super::pickups::Collector::Mob(p.player as u32)
};
if let Some(item) = self.dropped.iter().find(|d| d.id == p.item as u32).cloned() {
self.add_pickup(super::pickups::Taken::Item(Box::new(item)), collector);
} else if let Some(orb) = self.orbs.iter().find(|o| o.id == p.item as u32).cloned()
{
self.add_pickup(super::pickups::Taken::Orb(Box::new(orb)), collector);
} else if let Some(shot) = self
.creatures
.projectiles
.iter()
.find(|s| s.id == p.item as u32)
.cloned()
{
// An arrow flies to its collector too, then goes.
self.add_pickup(super::pickups::Taken::Arrow(Box::new(shot)), collector);
} else if let Some(i) = self.tridents.iter().position(|t| t.id == p.item as u32) {
// So does a trident: a server's client keeps them apart.
let trident = self.tridents.remove(i);
self.add_pickup(super::pickups::Taken::Trident(Box::new(trident)), collector);
}
// An orb stays: the server may still hold merged ones in it.
if !orb {
self.remove_entity(p.item);
}
}
// ClientPacketListener.handleSetEntityMotion: knockback on this player.
C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
if let Some(pl) = self.player.as_mut() {
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
}
}
// The same packet for another entity: Entity.lerpMotion.
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
// ClientPacketListener.handleProjectilePowerPacket: the power a
// hurting projectile accelerates by, sent bundled with its motion.
C::ProjectilePower(p) => {
super::remote_projectiles::remote_projectile_power(
&mut self.creatures.projectiles,
p.id,
p.power,
);
}
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
// jolts the walk animation and restarts the hurt flash, and the
// hurt player's own client plays its hurt sound (`Player.getHurtSound`:
// the damage type's effects) at `getSoundVolume` with a pitch that
// walks 1.0 ± 0.2 on the level random. The server's sound packet
// reaches every other player around, never this one (#4149).
// This player's own hurt tilt and flash come from the server too
// (sent to the player itself): its own moves never hurt it.
C::DamageEvent(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
let at = self
.player
.as_ref()
.map(|pl| [pl.position.x, pl.position.y, pl.position.z]);
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt();
}
if let Some(at) = at {
let sound = self.remote_hurt_sound(p.source_type);
let pitch = 1.0
+ (self.world.random.next_float() - self.world.random.next_float())
* 0.2;
self.audio.play_on(
horizon_client::settings::sound_source(sound),
sound,
Some(at),
1.0,
pitch,
);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
}
}
C::HurtAnimation(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
// Player.animateHurt: the tilt turns around the yaw.
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt_dir(p.yaw);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
}
}
C::EntityEvent(p) => {
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
self.remote_firework_explosion(p.id);
}
self.remote_fangs_event(p.id as u32, p.event);
self.remote_boat_event(p.id, p.event);
// Arrow.handleEntityEvent 0: a tipped arrow's burst of particles.
if p.event == 0 {
self.remote_arrow_puff(p.id as u32);
}
// LivingEntity.handleEntityEvent 2: a spear's hit felt.
if p.event == 2 {
self.remote_kinetic_hit(p.id);
}
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.ui.registries.permission = (p.event - 24) as u8;
self.remote_world_options_permission(p.event >= 26);
}
// Ravager.handleEntityEvent 69: the roar's puff and push.
if p.event == 69 {
self.remote_ravager_roar(p.id);
}
// ClientPacketListener.handleEntityEvent 21: a guardian's hum.
if p.event == 21 {
self.start_guardian_sound(p.id as u32);
}
// Witch.handleEntityEvent 15: the magic puffs over its hat.
if p.event == 15 {
self.witch_magic_particles(p.id as u32);
}
// Fox.handleEntityEvent 45: the crumbs of the food in its mouth.
if p.event == 45 {
self.fox_eat_particles(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
horizon_client::mobs::rabbit::hop_event(m);
}
if p.event == 19
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// LivingEntity.handleEntityEvent 55: another player swapped
// its main and off hand (`swapHandItems`).
if p.event == 55
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
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)
{
m.health = 0.0;
}
// LivingEntity.handleEntityEvent 3 on this player: its death
// sound, which Player.playSound plays for this client alone
// (the server's goes to everyone else).
if p.event == 3
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
&& let Some(pl) = self.player.as_ref()
{
let at = [pl.position.x, pl.position.y, pl.position.z];
let sound = "minecraft:entity.player.death";
let pitch = 1.0
+ (self.world.random.next_float() - self.world.random.next_float()) * 0.2;
self.audio.play_on(
horizon_client::settings::sound_source(sound),
sound,
Some(at),
1.0,
pitch,
);
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
{
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.death_poof([at.x, at.y, at.z], w, h);
}
if p.event == super::totem::TOTEM_EVENT {
self.remote_totem(p.id);
}
// Mob.handleEntityEvent 20: the spawn poof.
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
self.poof_particles(p.id as u32);
}
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
// 7, Animal and Allay 18: the particles about the mob.
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18 | 40 | 41) {
self.entity_event_particles(p.id as u32, p.event);
}
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
// the projectile is (the server removes it right after).
if p.event == 3 {
let crumbs = self
.creatures
.projectiles
.iter()
.find(|pr| pr.id == p.id as u32)
.and_then(|pr| {
let item = match pr.kind {
horizon_client::projectiles::ProjectileKind::Snowball => {
"minecraft:snowball"
}
horizon_client::projectiles::ProjectileKind::Egg => "minecraft:egg",
horizon_client::projectiles::ProjectileKind::Thrown(t) => t.item,
_ => return None,
};
Some(([pr.position.x, pr.position.y, pr.position.z], item))
});
if let Some((at, item)) = crumbs {
self.item_burst(at, item);
}
}
if p.event == super::combat::DROWN_EVENT {
self.remote_drown(p.id);
}
// Entity event 53: the honey slide particles of a player
// sliding down a honey block (HoneyBlock.showSlideParticles).
if p.event == 53 {
let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.player
.as_ref()
.map(|q| [q.position.x, q.position.y, q.position.z])
} else {
self.mobs
.iter()
.find(|m| m.id == p.id as u32)
.map(|m| [m.position.x, m.position.y, m.position.z])
};
if let Some(at) = at {
self.honey_slide_particles(at);
}
}
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.remote_complete_using();
}
}
C::Animate(p) => self.remote_animate(p.id, p.action),
// ClientPacketListener.handleSwingAnimation: the entity swings
// (LivingEntity.swing, which a swing in its first half ignores).
C::SwingAnimation(p) => {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let animation = horizon_client::first_person::SwingAnimation {
kind: p.kind.clamp(0, 255) as u8,
duration: p.duration,
};
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.swing_hand_modified(hand == Hand::Off, animation);
}
}
// The player's own instances: its maximum health (the hearts the
// HUD draws and the health it may hold), reach, speed and so on.
// Then the received health again, which the old maximum clamped.
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
if let Some(pl) = self.player.as_mut() {
horizon_client::lan::attributes::apply(&mut pl.attributes, &p.values);
}
self.apply_remote_health();
}
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
C::UpdateMobEffect(p) => self.remote_effect(&p),
C::RemoveMobEffect(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
&& let Some(id) = self.remote_effect_id(p.effect)
{
self.potions.effects.list.retain(|e| e.id != id);
}
}
C::ChangeDifficulty(p) => {
self.ui.difficulty = p.difficulty.clamp(0, 3) as u8;
self.level.difficulty_locked = p.locked;
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
}
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
C::SetHealth(p) => {
let flash = self.remote.as_mut().is_some_and(|r| {
r.health = (p.health, p.food, p.saturation);
std::mem::replace(&mut r.flash_on_set_health, true)
});
if flash && let Some(pl) = self.player.as_mut() {
pl.health.flash_health_change(p.health);
}
self.apply_remote_health();
// The death screen opens (or the respawn is asked for) with the
// death packet when the rule hides it.
let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen);
if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some()
{
self.close_dialogs();
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.ui.death_confirm = None;
self.capture(false);
}
}
C::PlayerCombatKill(p) => {
// The component resolves in the client's current language, as
// the chat line does, not in the language the session joined
// with (`Remote.lang` is the vanilla server-language table).
let message = remote_text(&p.message);
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
if let Some(r) = self.remote.as_mut() {
r.death_message = Some(message);
}
// ClientPacketListener.handlePlayerCombatKill: the death
// screen, whatever health was last sent, or `respawn()` at
// once when it is hidden.
if me && self.screen != Screen::Dead && self.player.is_some() {
if self.remote.as_ref().is_none_or(|r| r.show_death_screen) {
self.close_dialogs();
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.ui.death_confirm = None;
self.capture(false);
} else {
self.respawn_player()?;
}
}
}
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
C::SetExperience(p) => {
self.experience = Experience {
level: p.level,
progress: p.progress,
total: p.total,
}
}
// ClientPacketListener.handleSetTime: the game time, then
// ClientClockManager.handleUpdates: the clock catches up with the
// game time and takes the server's state. Only this level's
// default world clock is applied — a packet that only updates
// another clock (the End's) must not overwrite day_time, or the
// sky follows game_time and looks inverted vs the server's day.
C::SetTime(p) => {
self.world.game_time = p.game_time;
self.remote_clock_tick();
let clock = {
let r = self.remote.as_ref();
let names =
r.and_then(|r| r.registries.registries.get("minecraft:world_clock"));
let tracked = r.and_then(|r| r.clock);
let wanted = r
.and_then(|r| r.dimension.default_clock.clone())
.or_else(|| r.map(|r| r.dimension.name.clone()))
.unwrap_or_else(|| "minecraft:overworld".into());
let name = |id: i32| {
names
.and_then(|n| n.get(id as usize))
.map(|(n, _)| n.as_str())
};
p.clocks
.iter()
.find(|c| name(c.clock) == Some(wanted.as_str()))
.or_else(|| tracked.and_then(|id| p.clocks.iter().find(|c| c.clock == id)))
.cloned()
};
if let Some(c) = clock {
self.world.day_time = c.total_ticks;
if let Some(r) = self.remote.as_mut() {
r.clock = Some(c.clock);
r.clock_rate = c.rate;
r.clock_partial = c.partial_tick;
}
}
}
C::GameEvent(p) => match p.event {
// NO_RESPAWN_BLOCK_AVAILABLE: ClientPacketListener.handleGameEvent
// sends the player this system message.
0 => self.system_message(horizon_client::text::Text::translate(
"block.minecraft.spawn.not_valid",
&[],
)),
// LEVEL_CHUNKS_LOAD_START: the server has begun sending the level.
13 => {
if let Some(r) = self.remote.as_mut() {
r.level_load.loading_packets_received();
}
}
// GUARDIAN_ELDER_EFFECT: the apparition, and the curse sound
// unless the elder guardian is silent.
10 => {
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
if p.param == 1.0
&& let Some(at) = self.player.as_ref().map(|m| m.position)
{
self.audio.play(
"minecraft:entity.elder_guardian.curse",
Some([at.x, at.y, at.z]),
1.0,
1.0,
);
}
}
// WIN_GAME: the poem and credits, then PERFORM_RESPAWN.
4 if self.screen != Screen::Win => {
self.open_win_screen(true, super::win::Finish::RemoteRespawn);
}
1 => {
self.world.weather.raining = true;
self.world.weather.rain_level = (0.0, 0.0);
}
2 => {
self.world.weather.raining = false;
self.world.weather.rain_level = (1.0, 1.0);
}
3 => {
let mode = p.param as i32;
self.set_mode_id(mode);
if let Some(r) = self.remote.as_mut() {
r.game_mode = mode;
}
}
// IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen.
11 => {
if let Some(r) = self.remote.as_mut() {
r.show_death_screen = p.param == 0.0;
}
}
// PLAY_ARROW_HIT_SOUND: ClientPacketListener.handleGameEvent
// plays ARROW_HIT_PLAYER at player eye height.
6 => {
if let Some(at) = self.eye_position() {
self.audio
.play("minecraft:entity.arrow.hit_player", Some(at), 0.18, 0.45);
}
}
// RAIN_LEVEL_CHANGE: ClientPacketListener.handleGameEvent.
7 => {
self.world.weather.rain_level = (p.param, p.param);
self.world.weather.raining = p.param > 0.0;
}
// THUNDER_LEVEL_CHANGE: ClientPacketListener.handleGameEvent.
8 => {
self.world.weather.thunder_level = (p.param, p.param);
self.world.weather.thundering = p.param > 0.0;
}
// PUFFER_FISH_STING: ClientPacketListener.handleGameEvent
// plays PUFFER_FISH_STING at player position.
9 => {
if let Some(at) = self.player.as_ref().map(|m| m.position) {
self.audio.play(
"minecraft:entity.puffer_fish.sting",
Some([at.x, at.y, at.z]),
1.0,
1.0,
);
}
}
// LIMITED_CRAFTING: LocalPlayer.setDoLimitedCrafting.
12 => {
if let Some(r) = self.remote.as_mut() {
r.do_limited_crafting = p.param == 1.0;
}
}
_ => {}
},
C::PlayerAbilities(p) => {
self.creative = p.flags & 8 != 0;
if let Some(r) = self.remote.as_mut() {
r.abilities = Some(p);
}
self.apply_remote_abilities();
}
// ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the
// carried index stays, so the next tick answers with SetCarriedItem.
C::SetHeldSlot(p) => {
if (0..9).contains(&p.slot) {
self.selected = p.slot as usize;
}
}
C::ContainerSetContent(p) => {
self.remote_menu_state(p.container, p.state);
for (slot, item) in p.items.iter().enumerate() {
let stack = self.to_stack(item);
self.set_menu_slot(p.container, slot as i32, stack);
}
self.carried = self.to_stack(&p.carried);
self.remote_synced();
}
C::ContainerSetSlot(p) => {
self.remote_menu_state(p.container, p.state);
let stack = self.to_stack(&p.item);
self.tutorial_get_item(stack.as_ref());
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
// gains items squeezes for 5 ticks.
if p.container == 0
&& (36..=45).contains(&p.slot)
&& let Some(new) = &stack
{
let (pop, old) = match p.slot {
45 => (9, self.offhand.as_ref()),
s => (s as usize - 36, self.inventory[s as usize - 36].as_ref()),
};
if old.is_none_or(|old| old.count < new.count) {
self.hotbar_pop[pop] = 5;
}
}
let before = self.creative_stacks();
self.set_menu_slot(p.container, i32::from(p.slot), stack);
self.remote_synced_since(&before);
}
C::SetCursorItem(p) => {
let stack = self.to_stack(&p.item);
self.tutorial_get_item(stack.as_ref());
self.carried = stack;
}
C::SetPlayerInventory(p) => {
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,
// Inventory.SLOT_OFFHAND.
40 => self.offhand = stack,
_ => {}
}
self.remote_synced_since(&before);
}
C::ContainerSetData(p) if self.open_menu_name(p.container) == "minecraft:lectern" => {
self.remote_lectern_page(i32::from(p.value));
}
C::ContainerSetData(p) if self.remote_station_open(p.container) => {
self.remote_station_data(usize::try_from(p.id).unwrap_or(usize::MAX), p.value);
}
C::ContainerSetData(p) if self.native_screen_data(p.container, p.id, p.value) => {}
C::ContainerSetData(p) => {
let v = i32::from(p.value);
match self
.container
.and_then(|key| self.world.block_entities.get_mut(&key))
{
Some(BlockEntity::Furnace(f)) => match p.id {
0 => f.lit_remaining = v,
1 => f.lit_total = v,
2 => f.cooking_timer = v,
3 => f.cooking_total = v,
_ => {}
},
// CrafterBlockEntity.containerData.
Some(BlockEntity::Crafter(c)) => match usize::try_from(p.id) {
Ok(k @ 0..9) => c.disabled[k] = v == 1,
Ok(9) => c.triggered = v == 1,
_ => {}
},
// BrewingStandBlockEntity.dataAccess.
Some(BlockEntity::Brewing(b)) => match p.id {
0 => b.brew_time = v,
1 => b.fuel = v,
2 => b.total_brew_time = v,
3 => b.total_fuel = v,
_ => {}
},
_ => {}
}
}
C::OpenScreen(p) => self.remote_open_screen(p),
C::MountScreenOpen(p) => self.remote_open_mount(p),
C::RecipeBookAdd(p) => self.remote_recipe_book_add(p),
C::RecipeBookRemove(p) => self.remote_recipe_book_remove(p),
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
C::PlaceGhostRecipe(p) => self.remote_ghost_recipe(p),
C::MerchantOffers(p) => self.remote_merchant_offers(p),
C::ContainerClose(_) if self.remote_lectern_closed() => {}
C::ContainerClose(_) => {
if self.screen == Screen::Inventory {
self.remote_close_local();
self.screen = Screen::Playing;
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,
horizon_client::command::vanilla::dispatcher(),
);
// The chat's tree follows the server's now; a mod's commands
// go into it again (`chat_tree_for`).
let chat_tree = self.chat_tree_for(Some(&tree));
if let Some(r) = self.remote.as_mut() {
r.chat_tree = chat_tree;
r.chat_tree_received = true;
}
}
C::Cooldown(p) => {
// ClientPacketListener.handleItemCooldown: the server starts
// (or confirms) a group's cooldown; 0 ends it.
let group: Arc<str> = Arc::from(p.group.as_str());
if p.duration == 0 {
self.cooldowns.remove(&group);
} else {
self.cooldowns.add(group, p.duration);
}
}
// ClientPacketListener.handleGameTestHighlightPos.
C::GameTestHighlightPos(p) => self.highlight_test_pos(p.absolute, p.relative),
// ClientPacketListener.handleTestInstanceBlockStatus.
C::TestInstanceBlockStatus(p) => {
self.block_editor_status(remote_text(&p.status), p.size);
}
C::SystemChat(p) => {
// The component as sent, translated in the client's own
// language. ChatListener.handleOverlay for the action bar.
let text = remote_text(&p.content);
if p.overlay {
self.say_system_queued(&text);
self.titles.set_overlay(text, false);
} else if self.chat_line(text.clone(), horizon_client::chat::ChatSource::Server) {
// handleSystemMessage: said once the chat takes it.
self.say_system_chat_queued(&text);
}
}
C::PlayerChat(p) => {
use horizon_client::scoreboard::tag_to_json;
// SignedMessageBody.Packed.unpack: the last seen it names must
// be in the cache; then the message joins it
// (MessageSignatureCache.push).
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
// handlePlayerChat: the global index counts every chat packet; one out of
// order disconnects.
let expected = r.next_chat_index;
r.next_chat_index = r.next_chat_index.wrapping_add(1);
if p.global_index != expected {
log::error!(
"Missing or out-of-order chat message from server, expected index {expected} but got {}",
p.global_index
);
let reason = horizon_client::text::Text::translate(
"multiplayer.disconnect.bad_chat_index",
&[],
);
return self.leave_server(Some(reason.plain()));
}
let last_seen: Option<Vec<Signature>> =
p.last_seen.iter().map(|s| r.signatures.unpack(s)).collect();
let Some(last_seen) = last_seen else {
log::error!(
"Message from player with ID {:032x} referenced unrecognized signature id",
p.sender
);
let reason = horizon_client::text::Text::translate(
"multiplayer.disconnect.invalid_packet",
&[],
);
return self.leave_server(Some(reason.plain()));
};
let signature = p
.signature
.as_deref()
.and_then(horizon_protocol::secure_chat::signature);
r.signatures.push(&last_seen, signature.as_ref());
// The sender's PlayerInfo: none is an error line; else their
// `SignedMessageValidator` decides (`updateAndValidate`), and a message it
// takes without a signature of its own shows as unsigned.
if !r.tab_players.contains_key(&p.sender) {
log::error!(
"Received player chat packet for unknown player with ID: {:032x}",
p.sender
);
self.handle_chat_message_error(&p.chat_type, signature.as_ref());
return Ok(());
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
let incoming = secure_chat::Incoming {
sender: p.sender,
index: p.index,
signature: signature.as_ref(),
body: secure_chat::Body {
content: &p.content,
timestamp: p.timestamp,
salt: p.salt,
last_seen: &last_seen,
},
unsigned_content: p.unsigned_content.as_ref(),
filter: &p.filter,
};
let Some(validated) = r.validate_chat(&incoming, now) else {
self.handle_chat_message_error(&p.chat_type, signature.as_ref());
return Ok(());
};
let signature = signature.filter(|_| validated == secure_chat::Validated::Signed);
// ChatListener.handlePlayerChatMessage: Only Show Secure Chat
// shows the signed content, never the server's replacement.
// The narrator says the message's own decorated content
// (`narrateChatMessage(boundChatType, message.decoratedContent())`).
let decorated = p
.unsigned_content
.as_ref()
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
let content = if self.settings.file.bool("onlyShowSecureChat") {
serde_json::Value::from(p.content.as_str())
} else {
decorated.clone()
};
let narration = self
.decorate_chat(&p.chat_type, &decorated, Decoration::Narration)
.plain();
let line = self.decorate_chat(&p.chat_type, &content, Decoration::Chat);
let trust = self.chat_trust(
p.sender,
signature.is_some(),
p.timestamp,
&p.content,
p.unsigned_content.as_ref(),
&line,
);
// ChatListener.showMessageToPlayer: `Minecraft.isBlocked` hides the
// message of a player hidden in the Social Interactions screen; it
// counts as processed, not shown.
if self.social_hides(p.sender) {
if let Some(signature) = &signature {
self.mark_message_processed(signature, false);
}
return Ok(());
}
self.handle_player_chat(line, signature, trust, narration);
}
C::DisguisedChat(p) => {
let content = horizon_client::scoreboard::tag_to_json(&p.message);
let narration = self
.decorate_chat(&p.chat_type, &content, Decoration::Narration)
.plain();
let line = self.decorate_chat(&p.chat_type, &content, Decoration::Chat);
self.handle_player_chat(line, None, ChatTrust::Secure, narration);
}
C::DeleteChat(p) => self.handle_delete_chat(&p.signature)?,
C::PlayerInfoUpdate(p) => {
if let Some(r) = self.remote.as_mut() {
// handlePlayerInfoUpdate: new entries first, then every
// action on every known entry.
// INITIALIZE_CHAT: the session (or none) each entry carries; the
// player's validator is made again when their chat is next read.
if p.actions & 2 != 0 {
for e in &p.entries {
r.chat_sessions.insert(e.uuid, e.chat_session.clone());
r.chat_validators.remove(&e.uuid);
}
}
if p.actions & 1 != 0 {
for e in &p.entries {
if let Some(name) = &e.name {
r.tab_players.entry(e.uuid).or_insert_with(|| {
horizon_client::tab_list::TabPlayer::new(e.uuid, name.clone())
});
if let Some(t) = e.properties.iter().find(|p| p.name == "textures")
{
// Without the services' profile property keys a
// signature can't be checked; a signed property
// counts as genuine.
use horizon_client::skins::{self, Signature};
let signature = if t.signature.is_some() {
Signature::Signed
} else {
Signature::Unsigned
};
let textures =
skins::unpack(&t.value, signature, &skins::TEXTURE_URIS);
r.skin_textures.entry(e.uuid).or_insert(textures);
}
}
}
}
for e in p.entries {
if let Some(t) = r.tab_players.get_mut(&e.uuid) {
if let Some(mode) = e.game_mode {
t.game_mode = mode;
}
if let Some(latency) = e.latency {
t.latency = latency;
}
if let Some(name) = &e.display_name {
t.display_name =
name.as_ref().map(horizon_client::scoreboard::tag_to_json);
}
if let Some(order) = e.list_order {
t.list_order = order;
}
if let Some(hat) = e.show_hat {
t.show_hat = hat;
}
match e.listed {
Some(true) => {
r.listed.insert(e.uuid);
}
Some(false) => {
r.listed.remove(&e.uuid);
}
None => {}
}
}
if let Some(name) = e.name {
r.players.insert(e.uuid, name);
}
}
}
}
C::PlayerInfoRemove(p) => {
if let Some(r) = self.remote.as_mut() {
for u in p.uuids {
r.players.remove(&u);
r.tab_players.remove(&u);
r.chat_sessions.remove(&u);
r.chat_validators.remove(&u);
r.skin_textures.remove(&u);
r.listed.remove(&u);
}
}
}
C::TabList(p) => {
// handleTabListCustomisation: empty text clears it.
let text = |t: &Tag| {
let json = horizon_client::scoreboard::tag_to_json(t);
let text = horizon_client::scoreboard::comp_text(&json);
text.spans
.iter()
.any(|span| !span.text.is_empty())
.then_some(text)
};
self.tab_list.header = text(&p.header);
self.tab_list.footer = text(&p.footer);
}
C::InitializeBorder(p) => {
let b = &mut self.border;
b.set_center([p.center_x, p.center_z]);
if p.lerp_time > 0 {
b.lerp_between(p.old_size, p.new_size, p.lerp_time);
} else {
b.set_size(p.new_size);
}
b.set_absolute_max_size(p.absolute_max_size);
b.warning_distance = p.warning_blocks;
b.warning_time = p.warning_time;
}
C::SetBorderCenter(p) => self.border.set_center([p.x, p.z]),
C::SetBorderLerpSize(p) => {
self.border
.lerp_between(p.old_size, p.new_size, p.lerp_time)
}
C::SetBorderSize(p) => self.border.set_size(p.size),
C::SetBorderWarningDelay(p) => self.border.warning_time = p.delay,
C::SetBorderWarningDistance(p) => self.border.warning_distance = p.blocks,
C::SetObjective(p) => self.remote_objective(p),
C::SetDisplayObjective(p) => {
// DisplaySlot ids: list, sidebar, below_name, then the team sidebars.
let slot = match p.slot {
0 => "list".to_owned(),
2 => "below_name".to_owned(),
n @ 3..=18 => format!(
"sidebar.team.{}",
horizon_client::text::COLORS[(n - 3) as usize].1
),
_ => "sidebar".to_owned(),
};
let board = &mut self.score.board;
let objective = board.objective(&p.objective).map(|o| o.name.clone());
board.set_display_objective(&slot, objective.as_deref());
}
C::SetScore(p) => {
let board = &mut self.score.board;
if board.objective(&p.objective).is_some() {
let s = board.score_mut(&p.owner, &p.objective);
s.value = p.score;
s.display = p
.display
.as_ref()
.map(horizon_client::scoreboard::tag_to_json);
s.number_format = p.number_format.as_ref().map(number_format);
}
}
C::ResetScore(p) => match &p.objective {
Some(o) => self.score.board.reset_one(&p.owner, o),
None => self.score.board.reset_all(&p.owner),
},
C::SetPlayerTeam(p) => self.remote_team(p),
C::SetSimulationDistance(p) => {
if let Some(r) = self.remote.as_mut() {
r.simulation_distance = p.distance;
}
}
C::SetChunkCacheRadius(p) => {
self.streaming.radius = self.render_distance_in_use().min(p.distance + 1).max(2);
if let Some(r) = self.remote.as_mut() {
r.view_distance = p.distance;
}
// ClientChunkCache.updateViewRadius: a new storage of that size.
self.drop_remote_chunks_out_of_range();
}
C::SetChunkCacheCenter(p) => {
if let Some(r) = self.remote.as_mut() {
r.chunk_center = (p.x, p.z);
}
self.drop_remote_chunks_out_of_range();
}
C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag),
C::OpenSignEditor(p) => self.remote_open_sign_editor(p),
C::OpenBook(p) => self.remote_open_book(p.hand),
// handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at.
C::SetDefaultSpawnPosition(p) => {
let [x, y, z] = p.pos.pos;
self.spawn = (p.pos.dimension == "minecraft:overworld")
.then(|| Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5));
}
C::ServerData(p) => self.remote_server_data(p.icon.as_deref()),
C::MapItemData(p) => self.remote_map_item_data(&p),
// handleTickingState / handleTickingStep: the level's TickRateManager.
C::TickingState(p) => {
self.clock.set_rate(p.rate);
self.clock.set_frozen(p.frozen);
}
C::TickingStep(p) => self.clock.set_frozen_ticks_to_run(p.steps),
C::UpdateAdvancements(p) => {
let icons = p
.added
.iter()
.map(|a| {
a.display
.as_ref()
.map_or_else(String::new, |d| self.item_name(d.icon.item).to_string())
})
.collect();
self.remote_advancements(p, icons);
}
// handleAwardStats: StatsCounter.setValue for each.
C::AwardStats(p) => {
let Some(r) = self.remote.as_ref() else {
return Ok(());
};
let mut known = Vec::new();
for (ty, value, count) in p.stats {
let Some(ty) = r
.builtin
.get("minecraft:stat_type")
.and_then(|t| t.name(ty))
else {
continue;
};
let values = match ty {
"minecraft:mined" => "minecraft:block",
"minecraft:killed" | "minecraft:killed_by" => "minecraft:entity_type",
"minecraft:custom" => "minecraft:custom_stat",
_ => "minecraft:item",
};
if let Some(key) = r.builtin.get(values).and_then(|v| v.name(value)) {
known.push((ty.to_owned(), key.to_owned(), count));
}
}
for (ty, key, count) in known {
self.stats.values.entry(ty).or_default().insert(key, count);
}
}
// ClientPacketListener.setActionBarText, setTitleText,
// setSubtitleText, setTitlesAnimation and handleTitlesClear.
C::BossEvent(p) => self.apply_boss_event(&p),
C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false),
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
C::SetTitlesAnimation(p) => self.titles.set_times(p.fade_in, p.stay, p.fade_out),
// ClientCommonPacketListenerImpl.handleShowDialog / handleClearDialog.
C::ShowDialog(p) => self.show_play_dialog(&p.dialog),
C::ClearDialog(_) => self.clear_dialog(),
// ClientPacketListener.handleLowDiskSpaceWarning.
C::LowDiskSpaceWarning(_) => horizon_client::chunk_io::report_low_disk_space(),
C::ClearTitles(p) => {
self.titles.clear();
if p.value {
self.titles.reset_times();
}
}
_ => {}
}
Ok(())
}
/// `handleAddObjective`: add, remove or change an objective.
fn remote_objective(&mut self, p: play::SetObjective) {
use horizon_client::scoreboard::{RenderType, tag_to_json};
let board = &mut self.score.board;
match (p.method, p.display) {
(1, _) => board.remove_objective(&p.name),
(method, Some((name, render, format))) => {
let render = if render == 1 {
RenderType::Hearts
} else {
RenderType::Integer
};
let format = format.as_ref().map(number_format);
if method == 0 {
board.add_objective(
&p.name,
"dummy",
tag_to_json(&name),
render,
false,
format,
);
} else if let Some(o) = board.objective_mut(&p.name) {
o.display_name = tag_to_json(&name);
o.render_type = render;
o.number_format = format;
}
}
_ => {}
}
}
/// `handleSetPlayerTeamPacket`.
fn remote_team(&mut self, p: play::SetPlayerTeam) {
use horizon_client::scoreboard::{CollisionRule, Visibility, tag_to_json};
let board = &mut self.score.board;
if p.method == 1 {
board.remove_team(&p.name);
return;
}
if p.method == 0 {
board.add_team(&p.name);
}
if let Some(params) = &p.parameters
&& let Some(team) = board.team_mut(&p.name)
{
team.display_name = tag_to_json(&params.display_name);
team.prefix = tag_to_json(&params.prefix);
team.suffix = tag_to_json(&params.suffix);
team.color = params
.color
.and_then(|c| horizon_client::text::COLORS.get(c as usize))
.map(|c| c.1.to_owned());
team.allow_friendly_fire = params.options & 1 != 0;
team.see_friendly_invisibles = params.options & 2 != 0;
team.nametag_visibility = Visibility::ALL
.get(params.name_tag_visibility as usize)
.copied()
.unwrap_or(Visibility::Always);
team.collision_rule = CollisionRule::ALL
.get(params.collision_rule as usize)
.copied()
.unwrap_or(CollisionRule::Always);
}
match p.method {
0 | 3 => {
for player in &p.players {
board.add_to_team(player, &p.name);
}
}
// Scoreboard.removePlayerFromTeam: only out of this team (vanilla
// throws for a player in another one).
4 => {
for player in &p.players {
if board.players_team(player).is_some_and(|t| t.name == p.name) {
board.remove_from_team(player);
}
}
}
_ => {}
}
}
/// The player list's view of the connection: the listed players (with
/// their downloaded skins once ready), online mode, and which players
/// are in the level with an upside-down name.
pub(super) fn remote_tab_players(
&mut self,
) -> Option<(Vec<horizon_client::tab_list::TabPlayer>, bool, Vec<u128>)> {
self.update_remote_skins();
let mut players: Vec<_> = {
let r = self.remote.as_ref()?;
r.listed
.iter()
.filter_map(|u| r.tab_players.get(u).cloned())
.collect()
};
for p in &mut players {
p.skin = self
.remote
.as_ref()
.and_then(|r| r.skins.get(&p.uuid).cloned())
.unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned());
self.gui
.atlas
.set_object_skin(format!("id:{:032x}", p.uuid), p.skin.clone());
self.gui.atlas.set_object_skin(
format!("name:{}", p.name.to_ascii_lowercase()),
p.skin.clone(),
);
}
let r = self.remote.as_ref()?;
let upside_down = players
.iter()
.filter(|p| {
(p.name == "Dinnerbone" || p.name == "Grumm")
&& (p.uuid == r.profile || r.level_players.values().any(|u| *u == p.uuid))
})
.map(|p| p.uuid)
.collect();
Some((players, r.online_mode, upside_down))
}
/// Resolve inline `object:player` profiles through the same skin cache as
/// player heads on items. The next frame uses the uploaded atlas texture.
pub(super) fn resolve_inline_profiles(&mut self, profiles: &[serde_json::Value]) {
use horizon_client::skins;
for profile in profiles {
let skin = skins::head_skin(
profile,
&mut self.skin_profiles.borrow_mut(),
&mut self.skin_downloads.borrow_mut(),
|location| self.gui.atlas.sprites.contains_key(location),
);
let id = skins::parse_profile(profile).id;
let name = profile
.as_str()
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str));
// The player list's skin for the same profile is drawn too.
let keys = [
Some(format!("id:{id:032x}")),
name.map(|n| format!("name:{}", n.to_ascii_lowercase())),
];
for key in keys.iter().flatten() {
if let Some(listed) = self.gui.atlas.object_skins.get(key)
&& listed.starts_with("skins/")
{
let listed = listed.clone();
self.again_skin(&listed);
}
}
self.gui
.atlas
.set_object_skin(format!("id:{id:032x}"), skin.clone());
if let Some(name) = name {
self.gui
.atlas
.set_object_skin(format!("name:{}", name.to_ascii_lowercase()), skin);
}
}
}
fn poll_own_profile(&mut self) {
let Some(r) = self.remote.as_mut() else {
return;
};
match &mut r.own_profile {
OwnProfile::Idle => {
let Some(auth) = horizon_client::auth::current() else {
r.own_profile = OwnProfile::Done(None);
return;
};
let (send, recv) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("Own profile".into())
.spawn(move || {
let _ = send.send(horizon_client::auth::own_textures(auth.as_ref()));
})
.ok();
r.own_profile = OwnProfile::Fetching(recv);
}
OwnProfile::Fetching(recv) => {
if let Ok(own) = recv.try_recv() {
r.own_profile = OwnProfile::Done(own);
}
}
OwnProfile::Done(_) => {}
}
}
/// `PlayerInfo.getSkin` for every player entity, including NPCs that are
/// never in the tab list: resolve each profile's textures and give the
/// level's player models their skin.
pub(super) fn update_remote_skins(&mut self) {
let local = self.remote.as_ref().map(|r| r.profile);
self.poll_own_profile();
let Some(r) = self.remote.as_mut() else {
return;
};
let local = local.unwrap_or(r.profile);
let mut entries = skin_entries(
&r.level_players,
&r.skin_textures,
&mut r.level_player_textures,
);
let own = match &r.own_profile {
OwnProfile::Done(Some(t)) => Some(t.clone()),
_ => None,
};
// Minecraft.profileFuture: the signed-in account's active skin and cape
// from `/minecraft/profile` when the server sent none (single player) or
// only a skin without the equipped cape.
if let Some(own) = own {
if let Some((_, t)) = entries.iter_mut().find(|(u, _)| *u == local) {
if t.skin.is_none() {
t.skin = own.skin.clone();
if own.skin.is_some() {
t.slim = own.slim;
}
}
if t.cape.is_none() {
t.cape = own.cape.clone();
}
} else if own.skin.is_some() || own.cape.is_some() {
entries.retain(|(u, _)| *u != local);
entries.push((local, own));
}
}
let mut resolved: Vec<(u128, String)> = Vec::new();
// PlayerSkin.model of each resolved skin: the textures' `slim`.
let mut slim = std::collections::HashSet::new();
// PlayerSkin.cape of each resolved skin.
let mut capes: HashMap<u128, String> = HashMap::new();
for (uuid, textures) in entries {
// PlayerInfo.createSkinLookup: only the local player's own skin
// may be unsigned.
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
horizon_client::skins::lookup(&textures, uuid != local, |kind, url| {
downloads.state(kind, url)
})
};
if let Some(skin) = skin {
if textures.slim {
slim.insert(uuid);
}
if let Some(cape) = horizon_client::skins::cape(&textures) {
capes.insert(uuid, cape);
}
resolved.push((uuid, skin));
}
}
// An offline name's skin from the launcher, where neither the server
// nor an account gives the local player one.
if !resolved.iter().any(|(u, _)| *u == local)
&& let Some((rgba, is_slim)) = horizon_client::skins::offline_skin()
{
const OFFLINE: &str = "horizon/offline_skin";
if !self.skin_slots.contains(OFFLINE) || !self.gui.atlas.sprites.contains_key(OFFLINE) {
self.store_skin(OFFLINE.into(), rgba.clone());
}
if *is_slim {
slim.insert(local);
}
resolved.push((local, OFFLINE.into()));
}
// An offline name's cape from the launcher, where the profile has none.
if !capes.contains_key(&local)
&& let Some(rgba) = horizon_client::skins::offline_cape()
{
const OFFLINE: &str = "capes/offline";
if !self.skin_slots.contains(OFFLINE) || !self.gui.atlas.sprites.contains_key(OFFLINE) {
self.store_skin(OFFLINE.into(), rgba.to_vec());
}
capes.insert(local, OFFLINE.into());
}
// PlayerInfo.getSkin: the downloaded skin and its model, else the
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
Some(skin) => (skin.clone(), slim.contains(&uuid)),
None => {
let skin = horizon_client::tab_list::default_skin(uuid);
(skin.to_owned(), skin.contains("/slim/"))
}
};
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
r.skins = resolved.into_iter().collect();
(
r.level_players
.iter()
.map(|(id, uuid)| (*id as u32, *uuid))
.collect::<Vec<_>>(),
r.skins.clone(),
r.profile,
)
}) else {
return;
};
// Only the skins drawn hold atlas slots: the level's players, not
// every entry of a busy server's player list.
for (id, uuid) in level {
let Some(i) = self
.mobs
.iter()
.position(|m| m.id == id && &*m.kind.id == "minecraft:player")
else {
continue;
};
let (skin, is_slim) = self.drawn_skin(uuid, skin_of(&skins, uuid));
let cape = capes.get(&uuid).filter(|c| self.again_skin(c)).cloned();
let mob = &mut self.mobs[i];
mob.skin = Some(skin);
mob.slim = is_slim;
mob.cape = cape;
}
if self.player_model.is_some() {
let (skin, is_slim) = self.drawn_skin(own, skin_of(&skins, own));
// Avatar.isModelPartShown(CAPE): the Skin Customization option.
let cape = capes
.get(&own)
.filter(|_| self.settings.file.bool("modelPart_cape"))
.filter(|c| self.again_skin(c))
.cloned();
if let Some(mob) = self.player_model.as_mut() {
mob.skin = Some(skin);
mob.slim = is_slim;
mob.cape = cape;
}
}
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
// mannequin's DATA_PROFILE `textures` property resolves like a
// player's, never required to be signed.
let mannequins: Vec<(u32, horizon_client::skins::Textures)> = self
.mobs
.iter()
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
.map(|m| {
let profile = m.profile.as_ref();
let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
let textures = if let Some(t) = t {
let signature = if t.signature.is_some() {
horizon_client::skins::Signature::Signed
} else {
horizon_client::skins::Signature::Unsigned
};
horizon_client::skins::unpack(
&t.value,
signature,
&horizon_client::skins::TEXTURE_URIS,
)
} else {
// `ResolvableProfile.resolveProfile`: no `textures` and
// only a name or an id resolves through the services.
let key = profile.and_then(|p| match (&p.name, p.uuid) {
(Some(name), None) => {
Some(horizon_client::skins::NameOrId::Name(name.clone()))
}
(None, Some(id)) => Some(horizon_client::skins::NameOrId::Id(id)),
_ => None,
});
key.and_then(|key| match self.skin_profiles.borrow_mut().state(&key) {
horizon_client::skins::Resolve::Done(Some(profile)) => {
profile.textures().map(|(value, signature)| {
horizon_client::skins::unpack(
&value,
signature,
&horizon_client::skins::TEXTURE_URIS,
)
})
}
_ => None,
})
.unwrap_or_default()
};
(m.id, textures)
})
.collect();
let mut resolved: Vec<(u32, Option<String>, bool)> = Vec::new();
for (id, textures) in mannequins {
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
horizon_client::skins::lookup(&textures, false, |kind, url| {
downloads.state(kind, url)
})
};
let slim = skin.is_some() && textures.slim;
resolved.push((id, skin, slim));
}
for (id, skin, slim) in resolved {
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
{
mob.slim = slim && skin.is_some();
mob.skin = skin;
}
}
}
/// Puts a downloaded skin in an atlas skin slot (`SkinSlots::place`),
/// unless every slot holds a skin drawn in the last frame.
pub(super) fn store_skin(&mut self, location: String, rgba: Vec<u8>) {
let Some((slot, evicted)) = self.skin_slots.place(location.clone()) else {
return;
};
if let Some(old) = evicted {
self.gui.atlas.sprites.remove(&old);
}
let Some(mut rect) = self
.gui
.atlas
.sprites
.get(&format!("horizon/skin{slot}"))
.copied()
else {
return;
};
let (w, h) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
self.map_patches.push(horizon_client::font::Patch {
x: (rect.u0 * w).round() as u32,
y: (rect.v0 * h).round() as u32,
width: 64,
height: 64,
rgba,
});
// A cape is a 64x32 texture in the top half of the slot (skins::process_cape).
if location.starts_with("capes/") {
rect.v1 = (rect.v0 + rect.v1) / 2.0;
}
self.gui.atlas.sprites.insert(location, rect);
}
/// Whether a downloaded skin or cape is in the atlas to draw, marking it
/// drawn; if not (new, or its slot taken), its image is queued for upload
/// again and it is drawn from the next frame.
fn again_skin(&mut self, location: &str) -> bool {
let mut downloads = self.skin_downloads.borrow_mut();
// A slot without its sprite (the atlas was rebuilt, or the upload
// never happened) counts as missing, so the skin is stored again.
if self.skin_slots.contains(location) && self.gui.atlas.sprites.contains_key(location) {
downloads.drawn(location);
true
} else {
downloads.again(location);
false
}
}
/// A player's skin and model as drawn: the downloaded skin once it is in
/// the atlas, until then the profile's default (`DefaultPlayerSkin.get`),
/// as `PlayerInfo.getSkin` gives before the texture has loaded.
pub(super) fn drawn_skin(
&mut self,
uuid: u128,
(skin, slim): (String, bool),
) -> (String, bool) {
if (skin.starts_with("skins/") || skin == "horizon/offline_skin") && !self.again_skin(&skin)
{
let skin = horizon_client::tab_list::default_skin(uuid);
return (skin.to_owned(), skin.contains("/slim/"));
}
(skin, slim)
}
/// `drawn_skin` for a face in the player list or spectator menu.
pub(super) fn face_skin(&mut self, uuid: u128, skin: &str) -> String {
self.drawn_skin(uuid, (skin.to_owned(), false)).0
}
/// ChatType.Bound.decorate (`Decoration::Chat`) and `decorateNarration`
/// (`Decoration::Narration`): the chat type's decoration from the
/// server's registry, applied to `content`.
fn decorate_chat(
&self,
bound: &play::ChatTypeBound,
content: &serde_json::Value,
decoration: Decoration,
) -> horizon_client::text::Text {
use horizon_client::scoreboard::comp_text;
let Some(r) = self.remote.as_ref() else {
return comp_text(content);
};
let chat_type = r.registries.data("minecraft:chat_type", bound.chat_type);
comp_text(&chat_type_decorate(chat_type, decoration, bound, content))
}
fn remote_item_data(&mut self, id: i32, stack: &Option<Stack>) {
let Some(stack) = self.to_stack(stack) else {
return;
};
let position = self
.remote
.as_mut()
.and_then(|r| r.pending_items.remove(&id));
if let Some(item) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
item.stack = stack;
return;
}
let Some((p, velocity)) = position else {
return;
};
let at = Vec3::new(p[0], p[1], p[2]);
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: id as u32,
stack,
position: at,
previous: at,
velocity,
on_ground: false,
age: 0,
pickup_delay: 0,
bob_offset,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
});
}
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
/// entities whose own tick moves them on a client: dropped items, experience
/// orbs, falling blocks and primed TNT.
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
f.velocity = v;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id as u32) {
t.velocity = v;
} else if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id as u32) {
o.velocity = v;
} else if let Some(p) = self
.creatures
.projectiles
.iter_mut()
.find(|p| p.id == id as u32)
{
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
t.velocity = v;
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
r.velocity = v;
} else if self.remote_hook_motion(id as u32, v) {
} else if let Some(p) = self
.remote
.as_mut()
.and_then(|r| r.pending_items.get_mut(&id))
{
p.1 = v;
} else if !self.potions.remote_thrown_motion(id as u32, v) {
self.end.remote_eye_motion(id as u32, velocity);
}
}
fn apply_remote_health(&mut self) {
let Some((health, food, saturation)) = self.remote.as_ref().map(|r| r.health) else {
return;
};
if let Some(p) = self.player.as_mut() {
if (p.health.health() - health).abs() > f32::EPSILON {
let _ = p.set_health(health.max(0.0), false);
}
p.health.food.food = food;
p.health.food.saturation = saturation;
}
}
/// `ServerPlayer.dismountTo` lands a rider at the ghast's own (x, maxY, z)
/// (`HappyGhast.getDismountLocationForPassenger`). A vanilla client
/// simulates the ghast it rides, so its copy is where the server has it;
/// this window's copy trails the server's by the stepped interpolation and
/// would rise into the rider's feet, which lets the rider fall through.
/// Put the copy under the landing spot.
fn place_ghast_under(&mut self, id: u32, spot: Vec3) {
let Some(m) = self
.mobs
.iter_mut()
.find(|m| m.id == id && m.species() == horizon_client::mobs::Species::HappyGhast)
else {
return;
};
let at = Vec3::new(spot.x, spot.y - f64::from(m.height()), spot.z);
// Only a teleport that is that dismount: right above the ghast.
if (at.x - m.position.x).powi(2) + (at.z - m.position.z).powi(2) > 25.0
|| (at.y - m.position.y).abs() > 5.0
{
return;
}
m.position = at;
m.previous = at;
if let Some(handler) = self
.remote
.as_mut()
.and_then(|r| r.stepped.get_mut(&(id as i32)))
{
handler.cancel();
}
}
/// `ClientPacketListener.handleLookAt`: `Entity.lookAt` from this
/// player's feet or eyes, towards the packet's entity (its feet or eyes)
/// when this client tracks it, else towards the packet's point.
fn remote_look_at(&mut self, p: &play::PlayerLookAt) {
// `EntityAnchorArgument.Anchor`: 0 feet, 1 eyes.
const EYES: i32 = 1;
let Some(player) = self.player.as_ref() else {
return;
};
let mut from = [player.position.x, player.position.y, player.position.z];
if p.from == EYES {
from[1] += f64::from(player.dimensions.eye_height);
}
let to = p
.entity
.as_ref()
.and_then(|e| {
let m = self.mobs.iter().find(|m| m.id == e.id as u32)?;
let y = if e.anchor == EYES {
m.eye_y()
} else {
m.position.y
};
Some([m.position.x, y, m.position.z])
})
.unwrap_or(p.target);
(self.yaw, self.pitch) = look_at_rotation(from, to);
}
/// ClientPacketListener.handleMovePlayer.
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
use play::relative as rel;
let current = self
.player
.as_ref()
.map(|pl| pl.position)
.unwrap_or(Vec3::ZERO);
let velocity = self
.player
.as_ref()
.map(|pl| pl.velocity)
.unwrap_or(Vec3::ZERO);
let pick = |bit: i32, base: f64, v: f64| if p.relatives & bit != 0 { base + v } else { v };
let position = Vec3::new(
pick(rel::X, current.x, p.position[0]),
pick(rel::Y, current.y, p.position[1]),
pick(rel::Z, current.z, p.position[2]),
);
let yaw = if p.relatives & rel::Y_ROT != 0 {
self.yaw + p.yaw
} else {
p.yaw
};
let relative_pitch = p.relatives & rel::X_ROT != 0;
let pitch = position_packet_pitch(self.pitch, p.pitch, relative_pitch);
let delta = horizon_client::player_sync::teleport_velocity(
velocity,
(self.yaw, self.pitch),
(yaw, pitch),
p,
);
match self.player.as_mut() {
Some(player) => {
player.position = position;
player.velocity = delta;
}
None => {
let mut player = PlayerMotion::new(self.motion, position)?;
// Player.tick derives noPhysics from the current game mode.
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.pad.player_replaced();
self.sway = Default::default();
self.eye = 1.62;
self.previous_eye = 1.62;
self.clock.reset();
self.apply_remote_health();
self.apply_remote_abilities();
}
}
self.previous_position = position;
self.yaw = yaw;
self.pitch = pitch;
// setValuesFromPositionPacket: setOldPosAndRot gives xRotO the same
// change, so a relative pitch keeps the model's ease and an absolute
// one snaps it.
if let Some(m) = self.player_model.as_mut() {
m.previous_head_pitch =
position_packet_pitch(m.previous_head_pitch, p.pitch, relative_pitch);
m.head_pitch = pitch;
}
if let Some(id) = self.remote.as_mut().and_then(|r| r.ghast_dismount.take()) {
self.place_ghast_under(id, position);
}
if let Some(r) = self.remote.as_mut() {
// handleMovePlayer does not change LocalPlayer.sendPosition's
// baseline: the next tick must still report the corrected state.
r.destroying = None;
r.is_destroying = false;
}
self.mining = None;
self.send(S::AcceptTeleportation(play::AcceptTeleportation {
id: p.teleport,
x: position.x,
y: position.y,
z: position.z,
yaw,
pitch,
}));
Ok(())
}
/// `ClientPacketListener.tick`'s `LevelLoadTracker.tickClientLoad`: the
/// player's section is shown once its column is lit; when the level is
/// ready the loading screen closes and the server hears `PlayerLoaded`
/// (`notifyPlayerLoaded`).
fn tick_client_load(&mut self) {
let Some(player) = self.player.as_ref() else {
return;
};
if !self
.remote
.as_ref()
.is_some_and(|r| r.level_load.is_active())
{
return;
}
// `LevelRenderer`'s playerCompiledSectionCallback: the section holding
// the camera is there (the column is lit, as meshing needs).
let (cx, cz) = (
(player.position.x.floor() as i32) >> 4,
(player.position.z.floor() as i32) >> 4,
);
let section_ready = self.streaming.is_lit(cx, cz);
let camera_y = (player.position.y + f64::from(self.eye)).floor() as i32;
let shape = self.world.shape;
let check = horizon_client::level_loading::PlayerCheck {
outside_build_height: camera_y < shape.min_y || camera_y >= shape.min_y + shape.height,
spectator: player.spectator,
alive: player.health.health() > 0.0,
};
let now = client_millis();
// LevelLoadingScreen.tick.
self.tick_load_progress();
let Some(r) = self.remote.as_mut() else {
return;
};
if section_ready && r.level_load.wants_player_section() {
r.level_load.player_section_ready();
}
r.level_load.tick_client_load(now, check);
if !r.level_load.is_level_ready(now) {
return;
}
r.level_load.finish();
if !r.loaded_sent {
r.loaded_sent = true;
self.send(S::PlayerLoaded(Empty));
}
// LevelLoadingScreen.onClose.
self.say_system_now(&horizon_client::text::Text::translate(
"narrator.ready_to_play",
&[],
));
self.capture_loaded_world();
}
/// One client tick while connected (Minecraft.tick with a ClientLevel).
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.tick_remote_level()?;
// The rockets boosting this player tick after it, also while it waits
// for chunks.
self.tick_own_rockets();
// Client mods tick on any server, single player's own included.
self.native_net.to_server = 0;
self.native_tick();
self.tell_native_now();
// Far tiles the samplers asked for (`lod_net`).
if let Some(r) = &self.remote {
r.session.send_far_terrain();
}
self.tick_chat_session();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
if self.remote.as_ref().is_some_and(|r| r.joined) {
self.send(S::ClientTickEnd(Empty));
}
Ok(())
}
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
// Hud.tick ends with ChatComponent.tick.
self.ui.chat.tick(&self.gui.atlas, self.ticks);
self.tick_sign_editor();
self.track_experience_display();
self.tick_enchanting_book();
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
self.tick_ram_limit();
let info = self
.settings
.client_information(client_view_distance(self.render_distance_in_use()));
if let Some(r) = self.remote.as_mut()
&& r.joined
&& r.sent_info != info
{
r.sent_info = info.clone();
self.send(S::ClientInformation(info));
}
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
// the time only while the level runs normally.
let running = self.clock.world_runs();
self.boss_overlay.tick_darken(running);
self.tick_item_activation();
if running {
self.world.game_time += 1;
self.remote_clock_tick();
}
// Weather levels drift toward their targets between server updates.
let w = &mut self.world.weather;
w.rain_level.0 = w.rain_level.1;
w.thunder_level.0 = w.thunder_level.1;
if self.use_delay > 0 {
self.use_delay -= 1;
}
// Minecraft.handleKeybinds precedes entity ticking and sendChanges.
// Window callbacks only queue clicks, so an interaction cannot leak
// between CLIENT_TICK_END and a later pong.
self.remote_handle_input();
// Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after
// handleKeybinds: this path replaces `tick_world`'s for a server's
// client (multiplayer and the default single player), so the entries
// advance here or the slot stays shaded until the server says the
// entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on
// a client. Ticking it after the keybinds keeps a held use from
// trying again a tick before the server's entry ends.
self.cooldowns.tick();
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
for pop in &mut self.hotbar_pop {
*pop = pop.saturating_sub(1);
}
// ClientLevel.tick: the border's size change.
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
self.tick_random_textures();
// Breeze.tick's block dust: drawn by each client for the breezes it sees.
if running {
self.tick_breeze_effects();
}
self.tick_remote_guardian_eyes(running);
// Level.tickBlockEntities: a frozen level ticks no block entities.
// A step advances the moving pistons created by the block event once,
// rather than finishing them during the frozen ticks between steps.
if running {
self.tick_block_entity_anims();
self.tick_remote_spawners();
self.client_tick_beacons();
self.tick_remote_potent_sulfur();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.sync_moving_pistons();
}
self.tick_hand();
if running {
self.tick_particles();
}
self.tick_client_ambient();
self.tick_client_effects();
// Player.tick's sleep counter (the darkening and the wake-up fade).
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
// Gui.tick: the titles, the action bar and the held item's name.
self.tick_hud();
self.tick_subtitles();
self.tick_client_load();
let entity_collisions = self.player_entity_collisions();
let pushed = self.remote_pushes();
let sprint_window = self.settings.sprint_window;
let Some(player) = self.player.as_mut() else {
return Ok(());
};
let (cx, cz) = (
(player.position.x.floor() as i32) >> 4,
(player.position.z.floor() as i32) >> 4,
);
if !self.streaming.is_lit(cx, cz) {
// LocalPlayer.tick can wait for chunks, but Minecraft.tick still
// calls sendChanges: rotations and the 20-tick position reminder
// must continue, as well as moves into the unloaded area.
if let Some(r) = self.remote.as_ref()
&& r.loaded_sent
{
let input = r.last_input;
self.remote_send_state(input);
}
return Ok(());
}
let stick = self.pad_move();
if self.player.is_none() {
return Ok(());
}
// The LevelLoadingScreen takes the input until the level is ready.
let loading = self
.remote
.as_ref()
.is_some_and(|r| r.level_load.is_active());
let playing = self.screen == Screen::Playing && !loading && !self.dialog_open();
let held = |k| playing && self.keys.contains(&k);
let mut keys = Keys {
forward: held(KeyCode::KeyW),
backward: held(KeyCode::KeyS),
left: held(KeyCode::KeyA),
right: held(KeyCode::KeyD),
jump: held(KeyCode::Space),
shift: held(KeyCode::ShiftLeft),
sprint: held(KeyCode::ControlLeft),
analog: None,
}
.with_stick(stick.filter(|_| playing));
// On a server (not this game's own server thread), the stick moves as the keys it sets do
// (`KeyboardInput.tick`: 8 directions, full speed). The server only hears those keys
// (`ServerboundPlayerInputPacket`), and an anticheat that predicts from them pulls back
// a player moving at the stick's own angle and speed.
if self.integrated.is_none() && self.server_address().is_some() {
keys.analog = None;
}
let input = u8::from(keys.forward)
| u8::from(keys.backward) << 1
| u8::from(keys.left) << 2
| u8::from(keys.right) << 3
| 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()
};
// The held lead's end eases from the look at the start of the tick
// (`yRotO`/`xRotO`), as the local tick does; `tick` returns before it
// reaches its own write when this window is a server client.
self.rope_rot_old = (self.yaw, self.pitch);
// 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
// vehicles are now.
self.position_riders();
// Entity.getFluidInteractionBox asks the vehicle: a boat afloat shelters
// its passenger's eyes (AbstractBoat.modifyPassengerFluidInteractionBox).
let boat = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
.map(|b| horizon_client::motion::RiddenBoat {
under_water: b.under_water(),
max_y: b.bounds().max_position().y,
});
let Some(player) = self.player.as_mut() else {
return Ok(());
};
player.fluids.boat = boat;
if !riding {
self.previous_position = player.position;
}
let before = (
player.ground.on_ground,
[player.velocity.x, player.velocity.y, player.velocity.z],
[player.position.x, player.position.y, player.position.z],
);
let was_flying = player.flying;
self.sync_player_body();
// LivingEntity.tickDeath: the local player counts its deathTime once
// per client tick while dead (the camera's death roll and zoom).
if self.screen == Screen::Dead {
self.ui.death_time = self.ui.death_time.saturating_add(1);
}
let Some(player) = self.player.as_mut() else {
return Ok(());
};
let was_gliding = player.fall_flying;
let dead = self.screen == Screen::Dead;
// LivingEntity.isImmobile: a sleeper lies still in the bed.
let sleeping = self.sleeping;
if let Some(head) = sleeping {
player.position = Vec3::new(
f64::from(head[0]) + 0.5,
// setPosToBed: the bed's top (getSleepHeight) and 0.125 above it.
f64::from(head[1]) + 0.5625 + 0.125,
f64::from(head[2]) + 0.5,
);
player.velocity = Vec3::ZERO;
player.pitch = 0.0;
} else if !dead && !riding {
player.client_side = true;
player.velocity = player.velocity.add_vector(pushed);
MotionWorld {
entities: &entity_collisions,
player,
scene: &mut self.world,
assets: &self.pack.assets,
collision_binding: self.collision,
resources: self.motion,
}
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
} else {
// LivingEntity.baseTick counts hurtTime down for a dead or riding
// player too (controlled_simulation_tick does it otherwise): left
// at 10, the hurt tilt restarted every frame and shook the camera.
player.health.advance_clock();
if riding && !dead {
MotionWorld {
entities: &entity_collisions,
player: &mut *player,
scene: &mut self.world,
assets: &self.pack.assets,
collision_binding: self.collision,
resources: self.motion,
}
.passenger_pose_tick()?;
}
}
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
// jump, a spectator, landing) is sent to the server.
let flying = player.flying;
// LocalPlayer.aiStep: a glide this client started is sent to the
// server (ServerboundPlayerCommandPacket START_FALL_FLYING).
let started_gliding = player.fall_flying && !was_gliding;
let events = std::mem::take(&mut player.events);
self.previous_eye = self.eye;
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
horizon_client::motion::MotionEvent::Emission { state, muffled, .. } => {
let sounds = self.res.facts().get(state).sounds.clone();
// Player.playStepSound: muffled under the swim sound in water.
let (volume, pitch) = if muffled {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
(sounds.volume * 0.15, sounds.pitch)
};
self.audio.play(&sounds.step, Some(feet), volume, pitch);
}
horizon_client::motion::MotionEvent::Swim { volume, pitch } => {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
// Entity.doWaterSplashEffect on the client's own level: the
// server's splash skips this player (Player.playSound), and
// its particles are the client's own.
horizon_client::motion::MotionEvent::Splash {
high_speed,
volume,
pitch,
} => {
let name = if high_speed {
"minecraft:entity.player.splash.high_speed"
} else {
"minecraft:entity.player.splash"
};
self.audio.play(name, Some(feet), volume, pitch);
}
horizon_client::motion::MotionEvent::SplashParticle {
bubble,
position,
velocity,
} => {
use horizon_client::particles::{ParticleOptions, ParticleType};
let kind = if bubble {
ParticleType::Bubble
} else {
ParticleType::Splash
};
self.particles.add(
&ParticleOptions::simple(kind),
[position.x, position.y, position.z],
[velocity.x, velocity.y, velocity.z],
);
}
horizon_client::motion::MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
// NetherPortalBlock.entityInside on the client's own level:
// the portal overlay and its trigger sound (the server moves
// the player).
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
self.client_inside_nether_portal();
}
// HoneyBlock.maybeDoSlideEffects on the client's own level:
// the slide sound, heard here (LocalPlayer.playSound); the
// particles come from the server's event 53.
horizon_client::motion::MotionEvent::HoneySlide { sound: true, .. } => {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
}
// LivingEntity.causeFallDamage on the client's own level: the
// fall sound is heard here (LocalPlayer.playSound); the
// server's copy skips this player (Player.playSound).
horizon_client::motion::MotionEvent::Fall(
horizon_client::landing::FallEvent::Sound {
sound,
volume,
pitch,
..
},
) => {
if let Some(name) = sound.player_fall_sound() {
self.audio.play(name, Some(feet), volume, pitch);
}
}
_ => {}
}
}
// A stand riding the player follows this move (tickPassenger).
self.seat_own_stands();
// The server owns health and hunger.
self.apply_remote_health();
self.tick_avatar(before, false);
// LivingEntity.tick: a sleeper's xRot is 0.
if sleeping.is_some() {
self.pitch = 0.0;
self.snap_player_model_pitch();
}
// The player's own model (third person, the inventory's preview).
self.tick_player_model();
if !dead {
self.remote_tick_using();
}
if flying != was_flying {
self.send(S::PlayerAbilities(play::PlayerAbilitiesRequest {
flags: if flying { 2 } else { 0 },
}));
}
if started_gliding {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 6,
data: 0,
}));
}
// LocalPlayer.sendChanges sends `input.keyPresses`, which an auto-jump's `makeJump` set to
// jump for this tick: the server's prediction plays the jump from it. Sent as held, the jump
// was the client's alone, and the server saw a jump its keys didn't allow.
let auto_jumped = self.player.as_ref().is_some_and(|p| p.was_auto_jump);
self.remote_send_state(input | u8::from(auto_jumped) << 4);
Ok(())
}
/// ClientPacketListener.handlePlayerAbilities: the player flies, may fly
/// and is invulnerable as the server says (bits 2, 4 and 1).
fn apply_remote_abilities(&mut self) {
let (Some(player), Some(a)) = (
self.player.as_mut(),
self.remote.as_ref().and_then(|r| r.abilities.as_ref()),
) else {
return;
};
player.invulnerable = a.flags & 1 != 0;
player.flying = a.flags & 2 != 0;
player.may_fly = a.flags & 4 != 0;
player.flying_speed = a.flying_speed;
}
/// The local player's share of every touching entity's
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
/// `EntitySelector.pushableBy` lets entities push only the local player,
/// so the server never sends these pushes. `doPush(player)` is
/// `player.push(entity)`.
fn remote_pushes(&self) -> Vec3 {
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
return Vec3::ZERO;
};
// LivingEntity.isPushable: alive, no spectator, off ladders.
let feet = [
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
if self.screen == Screen::Dead
|| p.spectator
|| self.block_in_tag("minecraft:climbable", &self.block_name(feet))
{
return Vec3::ZERO;
}
let bounds = p.bounds();
let teams = self.mob_teams();
// `Entity.getTeam` is `Scoreboard.getPlayersTeam(getScoreboardName())`:
// a player's score holder is its profile name, every other entity's
// its UUID text. `mob_teams` keys by UUID and names the local player
// `PLAYER_NAME`, which misses a server's teams for players.
let board = &self.score.board;
let player_team = board.players_team(&r.profile_name);
let mut push = Vec3::ZERO;
for m in &self.mobs {
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let spectator = listed.is_some_and(|t| t.game_mode == SPECTATOR);
let mob_team = match listed {
Some(t) => board.players_team(&t.name),
None => teams.of(Who::Mob(m.id)),
};
// Bats never push; a spectator has no physics; passengers of the
// same vehicle never push each other.
if !m.alive()
|| spectator
|| m.species() == horizon_client::mobs::Species::Bat
|| self
.riding
.is_some_and(|v| v == m.id || m.extra.mount == Some(v))
|| !m.bounds().intersects(bounds)
|| !horizon_client::scoreboard::pushable_by(mob_team, player_team, true)
{
continue;
}
// Entity.push(Entity), `this` the player.
let (mut xa, mut za) = (m.position.x - p.position.x, m.position.z - p.position.z);
let mut dd = xa.abs().max(za.abs());
if dd < f64::from(0.01_f32) {
continue;
}
dd = dd.sqrt();
xa /= dd;
za /= dd;
let pow = (1.0 / dd).min(1.0);
xa *= pow;
za *= pow;
xa *= f64::from(0.05_f32);
za *= f64::from(0.05_f32);
push = Vec3::new(push.x - xa, push.y, push.z - za);
}
// AbstractBoat.push(player) from the boat's own tick (its box grown
// by 0.2 sideways) and from the player's pushEntities (its box).
let area = Vec3::new(f64::from(0.2_f32), f64::from(-0.01_f32), f64::from(0.2_f32));
for b in self.boats.iter().filter(|b| self.riding != Some(b.id)) {
let bb = b.bounds();
let times = usize::from(
bounds.intersects(bb.inflate(area))
&& horizon_client::scoreboard::pushable_by(None, player_team, true),
) + usize::from(
bounds.intersects(bb)
&& horizon_client::scoreboard::pushable_by(player_team, None, true),
);
if times > 0
&& super::boat_passengers::boat_pushes(bb, bounds, false)
&& let Some(v) = super::boat_passengers::push_impulse(b.position, p.position)
{
push = push.add_vector(v.scale(times as f64));
}
}
push
}
/// ClientLevel.tickEntities; a frozen level only ticks the players
/// (`TickRateManager.isEntityFrozen`).
/// `GuardianRenderer.getEntityToLookAt`: a guardian's eye follows its
/// synched attack target, else the camera.
fn tick_remote_guardian_eyes(&mut self, running: bool) {
use horizon_client::mobs::{Species, Who, guardian};
if !running {
return;
}
let camera = self.player.as_ref().map(|p| {
[
p.position.x,
p.position.y + f64::from(p.dimensions.eye_height),
p.position.z,
]
});
let looks: Vec<_> = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| {
matches!(m.species(), Species::Guardian | Species::ElderGuardian) && m.alive()
})
.filter_map(|(i, m)| {
let look = match m.ai.target.filter(|_| m.extra.flag) {
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.eye_y(), t.position.z]),
_ => camera,
};
look.map(|look| (i, look))
})
.collect();
for (i, look) in looks {
guardian::look_at(&mut self.mobs[i], look);
}
}
fn tick_remote_entities(&mut self, running: bool) {
if running {
self.tick_text_displays();
self.tick_remote_ominous_item_spawners();
}
// EntityFluidInteraction.update for the entities this window only displays: the
// server never sends the fluid contact, and the renderers read it (cod, salmon and
// tropical fish lie on their side without `in_water`, #1591).
// A frozen level ticks only the players, as below.
// Entity.isInRain for the wolves, which dry off by their own `Wolf.tick`.
let (fluids, rain, thundering): (Vec<_>, Vec<_>, bool) = {
let w = self.mob_world_base(&[]);
let players = self.remote.as_ref().map(|r| &r.level_players);
let fluids = self
.mobs
.iter()
.map(|m| {
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
.then(|| w.fluids(m.bounds(), m.eye_y()))
})
.collect();
let rain = self
.mobs
.iter()
.map(|m| {
m.species() == horizon_client::mobs::Species::Wolf
&& horizon_client::mobs::tame::in_rain(m, &w)
})
.collect();
(fluids, rain, w.thundering)
};
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
if let Some(fluids) = fluids {
m.fluids = fluids;
}
}
let game_time = self.world.game_time;
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
let nether = self.dim == super::Dim::Nether;
// What a wither's side heads can look at, by network entity id: the
// mobs and the player (position and eye height).
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
.mobs
.iter()
.any(|m| m.species() == horizon_client::mobs::Species::Wither)
{
let own = self
.remote
.as_ref()
.zip(self.eye_position())
.map(|(r, eye)| (r.entity_id, eye, eye[1]));
self.mobs
.iter()
.map(|m| {
(
m.id as i32,
[m.position.x, m.position.y, m.position.z],
m.eye_y(),
)
})
.chain(own)
.collect()
} else {
Vec::new()
};
// ExperienceOrb.followNearbyPlayer: the players an orb may go for,
// their feet and the point `eyeHeight / 2` above them.
let followed: Vec<([f64; 3], [f64; 3])> = {
let own = self
.player
.as_ref()
.filter(|p| !p.health.dead_or_dying() && !self.spectator())
.map(|p| {
let feet = [p.position.x, p.position.y, p.position.z];
let eye = f64::from(p.dimensions.eye_height);
(feet, [feet[0], feet[1] + eye / 2.0, feet[2]])
});
let others = self.remote.as_ref().into_iter().flat_map(|r| {
self.mobs
.iter()
.filter(move |m| m.alive() && r.level_players.contains_key(&(m.id as i32)))
.filter(move |m| {
let listed = r.level_players.get(&(m.id as i32));
!listed
.and_then(|uuid| r.tab_players.get(uuid))
.is_some_and(|t| t.game_mode == SPECTATOR)
})
.map(|m| {
let feet = [m.position.x, m.position.y, m.position.z];
(
feet,
[feet[0], feet[1] + (m.eye_y() - feet[1]) / 2.0, feet[2]],
)
})
});
own.into_iter().chain(others).collect()
};
// 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);
// Cat.handleLieDown: the sleeping players (another's, drawn lying in
// a bed, and this client's own) a lying cat can lie on top of.
let sleepers: Vec<horizon_core::geometry::Aabb> = self
.remote
.as_ref()
.map(|r| {
self.mobs
.iter()
.filter(|m| {
r.level_players.contains_key(&(m.id as i32)) && m.extra.ints[1] != 0
})
.map(|m| {
let p = m.position;
horizon_core::geometry::Aabb::new(
horizon_core::geometry::Vec3::new(p.x - 0.1, p.y, p.z - 0.1),
horizon_core::geometry::Vec3::new(p.x + 0.1, p.y + 0.2, p.z + 0.1),
)
})
.chain(self.sleeping.map(|bed| {
let p = horizon_core::geometry::Vec3::new(
f64::from(bed[0]) + 0.5,
f64::from(bed[1]) + 0.6875,
f64::from(bed[2]) + 0.5,
);
horizon_core::geometry::Aabb::new(
horizon_core::geometry::Vec3::new(p.x - 0.1, p.y, p.z - 0.1),
horizon_core::geometry::Vec3::new(p.x + 0.1, p.y + 0.2, p.z + 0.1),
)
}))
.collect()
})
.unwrap_or_default();
let Some(r) = self.remote.as_mut() else {
return;
};
let mut lerps = std::mem::take(&mut r.lerps);
let mut cart_lerps = std::mem::take(&mut r.cart_lerps);
let stepped = &mut r.stepped;
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let tab_players = &r.tab_players;
let mut using = Vec::new();
let mut mob_fx = Vec::new();
for (m, rain) in self.mobs.iter_mut().zip(rain) {
let player = players.contains_key(&(m.id as i32));
if player {
// AvatarRenderer.extractRenderState: isSpectator, from the player list.
m.extra.spectator = players
.get(&(m.id as i32))
.and_then(|uuid| tab_players.get(uuid))
.is_some_and(|t| t.game_mode == SPECTATOR);
}
if (!running && !player) || driven == Some(m.id) {
continue;
}
m.previous = m.position;
// `Entity.deltaMovementOnPreviousTick`: the elytra's flight yaw
// lerps from it, so another player's model does not snap when a
// new tick's velocity arrives.
m.extra.previous_velocity = m.velocity;
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
m.previous_head_yaw = m.head_yaw;
m.previous_head_pitch = m.head_pitch;
m.previous_limb_speed = m.limb_speed;
m.age += 1;
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::base_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Horse
| horizon_client::mobs::Species::Donkey
| horizon_client::mobs::Species::Mule
| horizon_client::mobs::Species::SkeletonHorse
| horizon_client::mobs::Species::ZombieHorse
) {
horizon_client::mobs::equine::tail_tick(m);
}
if m.species() == horizon_client::mobs::Species::Creeper {
horizon_client::mobs::hostile::creeper_client_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Guardian
| horizon_client::mobs::Species::ElderGuardian
) {
horizon_client::mobs::guardian::client_tick(m);
}
// PolarBear.tick: the stand animation follows the synched flag.
if m.species() == horizon_client::mobs::Species::PolarBear {
horizon_client::mobs::polar_bear::client_tick(m);
}
// The particles these mobs' own ticks draw on the client side.
{
use horizon_client::mobs::{
Species as S, cube, illager, illusioner, nautilus, tame, wither,
};
match m.species() {
S::Wolf => tame::client_tick(m, rain, &mut mob_fx),
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
S::Illusioner => {
illusioner::client_tick(m, &mut mob_fx);
illager::cast_particles(m, &mut mob_fx);
}
S::Evoker => illager::cast_particles(m, &mut mob_fx),
S::Wither => wither::client_smoke(m, &mut mob_fx),
_ => {}
}
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
if !m.alive() {
m.death_time += 1;
}
// LivingEntity.updateSwingTime.
m.anim.attack_anim_o = m.anim.attack_anim;
horizon_client::mobs::ai::tick_swing(m);
// Entity.tick: the SteppedInterpolationHandler's step.
if let Some(at) = stepped
.get_mut(&(m.id as i32))
.and_then(|h| h.interpolate())
{
place_interpolated(m, at);
}
// LivingEntity.aiStep: the head's own interpolation.
if let Some((yaw, steps)) = head_lerps.get_mut(&(m.id as i32))
&& *steps > 0
{
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
*steps -= 1;
}
// Fox.tick: the head tilt and crouch follow the synched flags.
if m.species() == horizon_client::mobs::Species::Fox {
horizon_client::mobs::fox::client_tick(m);
}
// Cat.tick: the lying and relaxing amounts follow the synched flags.
if m.species() == horizon_client::mobs::Species::Cat {
m.anim.vo = m.anim.v;
horizon_client::mobs::tame::cat_amounts(m);
horizon_client::mobs::tame::cat_lying_on_sleeper(m, &sleepers);
}
// Chicken.aiStep: the wings follow the server's on-ground flag.
if m.species() == horizon_client::mobs::Species::Chicken {
horizon_client::mobs::farm::chicken_flap(m);
}
// Parrot.calculateFlapping: the wings follow the server's
// on-ground flag and the shoulder it sits on (the glide is the
// server's).
if m.species() == horizon_client::mobs::Species::Parrot {
horizon_client::mobs::parrot::flap(m);
}
// LivingEntity.updatingUsingItem: the ticks the item has been
// used before this one. Another player's use particles; a mob's
// drawn bow only needs the count (its pull follows it).
if player {
// AvatarRenderer.getArmPose: the used item's BLOCK animation
// (an item with `blocks_attacks`) raises the arm.
let used = if m.extra.using_off_hand {
&m.extra.off_hand
} else {
&m.extra.main_hand
};
m.extra.blocking = m.extra.using_item > 0
&& used
.as_ref()
.and_then(|i| world_items!(self).get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
if m.extra.using_item > 0 {
if player {
let item = if m.extra.using_off_hand {
m.extra.off_hand.clone()
} else {
m.extra.main_hand.clone()
};
if let Some(item) = item {
let eye = m.eye_height();
let at = [m.position.x, m.position.y, m.position.z];
using.push((
item,
m.extra.using_item as u32 - 1,
at,
eye,
m.head_pitch,
m.yaw,
));
}
}
m.extra.using_item += 1;
}
// LivingEntity.tick's tickHeadTurn: a mob's BodyRotationControl,
// a player's body following its walk and look.
if player {
remote_player_head_turn(m);
// LivingEntity.tick: the glide count, wings and swim amount.
m.tick_avatar_pose(m.fluids.in_water);
// LivingEntity.tick: a sleeper's xRot is 0.
if m.extra.ints[1] != 0 {
m.head_pitch = 0.0;
m.previous_head_pitch = 0.0;
}
} else if m.alive() {
m.body_rotation_tick();
}
// Squid.aiStep follows LivingEntity's interpolation and head turn.
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
// WalkAnimationState.update from the horizontal distance moved.
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let (target, factor) = if horizon_client::mobs::camel::is_camel(m.species()) {
// Camel.updateWalkAnimation.
horizon_client::mobs::camel::walk_target(m, (dx * dx + dz * dz).sqrt() as f32)
} else {
((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4)
};
m.limb_speed += (target - m.limb_speed) * factor;
m.limb_position += m.limb_speed;
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::post_tick(m);
}
if m.species() == horizon_client::mobs::Species::Bat {
horizon_client::mobs::bat::setup_animation_states(m);
}
// The animation states a server computes in its own `ai_step`.
horizon_client::mobs::sync::client_tick(m, game_time);
match m.species() {
// WitherBoss.aiStep: the side heads turn toward their targets.
horizon_client::mobs::Species::Wither => {
horizon_client::mobs::wither::client_tick(m, |id| {
wither_targets
.iter()
.find(|t| t.0 == id)
.map(|t| (t.1, t.2))
});
}
// Hoglin and piglin `isConverting` outside the Nether.
horizon_client::mobs::Species::Hoglin
| horizon_client::mobs::Species::Piglin
| horizon_client::mobs::Species::PiglinBrute => {
horizon_client::mobs::piglin::client_converting(m, nether);
}
horizon_client::mobs::Species::Panda => {
horizon_client::mobs::panda::client_tick(m, thundering);
}
_ => {}
}
}
// ExperienceOrb.tick runs on a client too (all but the merges and the
// push out of a block, which are the server's): gravity, buoyancy,
// collisions and the pull of the nearest player between the server's
// updates; the server removes it on pickup.
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
let mut lava_random = None;
let mut next_float = || {
lava_random
.get_or_insert_with(super::dispenser::entity_random)
.next_float()
};
if running {
self.orbs.retain_mut(|o| {
let distance = |feet: &[f64; 3]| -> f64 {
(feet[0] - o.position.x).powi(2)
+ (feet[1] - o.position.y).powi(2)
+ (feet[2] - o.position.z).powi(2)
};
let target = followed
.iter()
.filter(|(feet, _)| distance(feet) < 64.0)
.min_by(|a, b| distance(&a.0).total_cmp(&distance(&b.0)))
.map(|(_, target)| *target);
// Entity.commonTick: setOldPosAndRot, then
// LinearInterpolationHandler.doInterpolate moves 1/remaining of
// the way to the server's position, so the frame draws the step
// from the old position.
let old = o.position;
if let Some(l) = lerps.get_mut(&(o.id as i32)).filter(|l| l.steps > 0) {
let alpha = 1.0 / f64::from(l.steps);
o.position = Vec3::new(
o.position.x + (l.target[0] - o.position.x) * alpha,
o.position.y + (l.target[1] - o.position.y) * alpha,
o.position.z + (l.target[2] - o.position.z) * alpha,
);
l.steps -= 1;
}
let before = o.position;
let kept = o.tick(world, facts, collision, target, false, &mut next_float);
// `tick` set `previous` to the stepped position: keep the
// pre-step one, as `xo` is in vanilla.
o.previous = old;
// AbstractInterpolationHandler.applyPredictedMovement: the orb's
// own move carries the target along (vanilla also checks the
// moved target for collisions; that check is left out here).
if let Some(l) = lerps.get_mut(&(o.id as i32)).filter(|l| l.steps > 0) {
l.target[0] += o.position.x - before.x;
l.target[1] += o.position.y - before.y;
l.target[2] += o.position.z - before.z;
}
kept
});
}
// ItemEntity.tick runs on a client too: gravity, collisions and
// friction move the item between the server's updates (every 20
// ticks), and only the server removes it.
let (world, registry, facts, collision) = (
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
);
for d in self.dropped.iter_mut().filter(|_| running) {
d.tick(
world,
registry,
facts,
collision,
false,
&self.res.motion().fluids,
// The server decides what an item rests on; this copy only moves between updates.
&[],
);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
let _ = self.handle_mob_events(mob_fx);
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
self.guardian_bubbles();
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
.as_ref()
.and_then(|d| lerps.get_mut(&(d.id as i32)))
.filter(|l| l.steps > 0)
.map(|l| {
let steps = l.steps;
l.steps -= 1;
(l.target, l.rotation.map(|r| r.0), steps)
});
self.tick_remote_dragon(dragon_lerp);
// Armor stands: ClientLevel.tickNonPassenger's setOldPosAndRot,
// then their SteppedInterpolationHandler's step, as the mobs'.
if let Some(r) = self.remote.as_mut() {
for a in &mut self.armor_stands {
let step = r
.stepped
.get_mut(&(a.id as i32))
.and_then(|h| h.interpolate());
a.interpolate(step.map(|at| {
let p = at.position;
(Vec3::new(p[0], p[1], p[2]), at.yaw)
}));
}
}
// Boats, minecarts, projectiles and the rest ease toward the
// server's position over three ticks (`lerps`).
let mut step = |id: u32, at: Vec3| match lerps.get_mut(&(id as i32)) {
Some(l) if l.steps > 0 => {
let n = f64::from(l.steps);
l.steps -= 1;
Vec3::new(
at.x + (l.target[0] - at.x) / n,
at.y + (l.target[1] - at.y) / n,
at.z + (l.target[2] - at.z) / n,
)
}
_ => at,
};
self.tick_remote_objects(&mut step, |c| {
cart_lerps
.get_mut(&(c.id as i32))
.is_some_and(|l| l.tick(c))
});
self.tick_remote_projectiles(&mut step);
// LightningBolt.tick on a client, and the sky flash fading.
self.flash = self.flash.saturating_sub(1);
self.tick_bolts();
}
lerps.retain(|_, l| l.steps > 0);
if let Some(r) = self.remote.as_mut() {
for (id, l) in lerps {
r.lerps.entry(id).or_insert(l);
}
for (id, l) in cart_lerps {
r.cart_lerps.entry(id).or_insert(l);
}
}
}
/// `ClientPacketListener.handleMinecartAlongTrack`: the steps join the ones
/// the minecart has queued, if it runs `NewMinecartBehavior`.
fn remote_move_minecart(&mut self, p: play::MoveMinecartAlongTrack) {
if !self.carts.iter().any(|c| c.id == p.id as u32) {
return;
}
let Some(r) = self.remote.as_mut() else {
return;
};
let at = |v: [f64; 3]| Vec3::new(v[0], v[1], v[2]);
r.cart_lerps
.entry(p.id)
.or_insert_with(CartLerp::new)
.pending
.extend(p.steps.iter().map(|s| CartStep {
position: at(s.position),
movement: at(s.movement),
yaw: horizon_protocol::buf::unpack_degrees(s.yaw),
weight: s.weight,
}));
}
/// `Consumable.onConsumeTick` for another player using a consumable: the
/// crumbs at their mouth every 4 ticks once 21.875% of its use has passed
/// (`shouldEmitParticlesAndSounds`; the sounds come from the server).
fn remote_player_eating(
&mut self,
item: &str,
ticks: u32,
at: [f64; 3],
eye: f64,
pitch: f32,
yaw: f32,
) {
let Some(consumable) = self
.res
.items()
.get(item)
.and_then(|i| i.consumable.as_ref())
.filter(|c| c.particles)
else {
return;
};
let duration = consumable.ticks().max(0) as u32;
let remaining = duration - ticks.min(duration);
if !consumable.should_emit(remaining as i32) {
return;
}
let options = self.item_particle(&ItemStack::new(item, 1));
let mut spawned = Vec::new();
horizon_client::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);
}
}
/// LocalPlayer.sendPosition plus input, sprint and held-slot updates.
fn remote_send_state(&mut self, input: u8) {
let Some(p) = self.player.as_ref() else {
return;
};
let (pos, on_ground, collision, sprinting) = (
[p.position.x, p.position.y, p.position.z],
p.ground.on_ground,
p.collision.horizontal,
p.sprinting,
);
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;
};
let mut out = Vec::new();
if r.carried != Some(selected) {
r.carried = Some(selected);
out.push(S::SetCarriedItem(play::SetCarriedItem {
slot: selected as i16,
}));
}
if input != r.last_input {
r.last_input = input;
out.push(S::PlayerInput(play::PlayerInput { flags: input }));
}
if sprinting != r.was_sprinting {
r.was_sprinting = sprinting;
out.push(S::PlayerCommand(play::PlayerCommand {
id: r.entity_id,
action: if sprinting { 1 } else { 2 },
data: 0,
}));
}
if riding {
// LocalPlayer.tick: a passenger sends its look, then the vehicle
// it controls.
out.push(S::MovePlayerRot(play::MovePlayerRot {
yaw,
pitch,
flags: u8::from(on_ground) | u8::from(collision) << 1,
}));
out.extend(vehicle);
} 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);
}
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
let mut creative_slots = Vec::new();
if self.creative {
// A server's open menu takes clicks, which it applies itself: what
// this client predicts for them isn't an edit to send (it would
// overwrite the server's stacks whenever the prediction is wrong).
let in_menu = r.menu.is_some();
let stacks = creative_sync_stacks(&self.inventory, &self.offhand, &self.armor);
for (i, stack) in stacks.into_iter().enumerate() {
if stack != r.synced[i] {
r.synced[i] = stack.clone();
if !in_menu {
creative_slots.push((creative_sync_slot(i), stack));
}
}
}
}
for packet in out {
self.send(packet);
}
for (slot, stack) in creative_slots {
let item = self.to_network(&stack);
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item,
}));
}
}
fn creative_stacks(&self) -> Vec<Option<ItemStack>> {
creative_sync_stacks(&self.inventory, &self.offhand, &self.armor)
}
/// `CreativeInventoryListener.slotChanged`: `handleCreativeModeItemAdd` for every slot the
/// player's inventory menu changed. `CreativeModeInventoryScreen.slotClicked` ends in
/// `broadcastChanges`, so a click's slots go out with the click, right behind a thrown
/// stack, and not at the end of the tick: a slot update the server sends meanwhile (the
/// pickup of what a held Q threw) can then never land on a change still unsent and undo it.
pub(super) fn send_creative_slots(&mut self) {
if !self.creative {
return;
}
let stacks = self.creative_stacks();
let Some(r) = self.remote.as_mut() else {
return;
};
let mut changed = Vec::new();
for (i, stack) in stacks.into_iter().enumerate() {
if stack != r.synced[i] {
r.synced[i] = stack.clone();
changed.push((creative_sync_slot(i), stack));
}
}
for (slot, stack) in changed {
let item = self.to_network(&stack);
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item,
}));
}
}
/// Inventory as the server last described it.
fn remote_synced(&mut self) {
let stacks = self.creative_stacks();
if let Some(r) = self.remote.as_mut() {
r.synced = stacks;
}
}
/// The slots a server packet changed since `before` are as the server has them; the
/// others keep what was last sent, so a creative click made since still goes out.
fn remote_synced_since(&mut self, before: &[Option<ItemStack>]) {
let now = self.creative_stacks();
if let Some(r) = self.remote.as_mut() {
mark_synced(&mut r.synced, before, now);
}
}
/// `MultiPlayerGameMode.stopDestroyBlock`: aborts the break and starts the attack strength
/// again, if one is running (`if (!isDestroying) return;`), and says whether it did. Only the
/// progress goes: `destroyBlockPos` and `destroyingItem` keep the target, so a dig resumed on
/// the same block sends no START and the server's clock runs on (#4124).
pub(super) fn stop_remote_mining(&mut self) -> bool {
let kept = self.remote.as_mut().and_then(|r| {
if !std::mem::take(&mut r.is_destroying) {
return None;
}
let (pos, face, _) = r.destroying?;
r.destroying = Some((pos, face, 0.0));
r.destroying
});
let Some((pos, _, _)) = kept else {
return false;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
self.tutorial_destroy(state, -1.0);
self.send(S::PlayerAction(play::PlayerAction {
action: 2,
pos,
direction: 0,
sequence: 0,
}));
self.reset_attack_strength();
true
}
/// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock.
fn remote_mining(&mut self, start: bool) {
let debug_stick = self
.held()
.is_some_and(|stack| &*stack.item == "minecraft:debug_stick");
if !self
.res
.items()
.can_destroy_block(self.held(), self.creative)
{
self.stop_remote_mining();
self.mining = None;
return;
}
let Some(r) = self.remote.as_mut() else {
return;
};
if !start && r.destroy_delay > 0 {
r.destroy_delay -= 1;
return;
}
let Some(hit) = self.pick() else {
self.mining = None;
return;
};
let face = face_direction(hit.face);
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if self.res.facts().is_air(state) {
return;
}
// startDestroyBlock refuses a restricted break before predicting it.
if self.remote_break_restricted() {
self.mining = None;
return;
}
if self.creative {
let seq = self.next_sequence();
// DebugStickItem.canDestroyBlock never lets the client break the
// block; the server only selects the property on this START.
if !debug_stick {
self.predict_break(hit.pos, state, seq);
}
self.send(S::PlayerAction(play::PlayerAction {
action: 0,
pos: hit.pos,
direction: face,
sequence: seq,
}));
if let Some(r) = self.remote.as_mut() {
r.destroy_delay = 5;
}
return;
}
let facts = self.res.facts().get(state).clone();
// The server's own getDestroyProgress: under water, off the ground,
// with haste, fatigue and efficiency, so the client stops digging
// when the server is done too (#1254).
let progress = if facts.hardness == 0.0 {
1.0
} else {
self.destroy_progress(hit.pos)
};
// sameDestroyTarget: the same block and still the item the break began with.
let held = self.main_hand_key();
let same = self.remote.as_ref().is_some_and(|r| {
r.destroying.is_some_and(|(p, _, _)| p == hit.pos) && r.destroying_item == held
});
// startDestroyBlock leaves only a break that is still running alone (`isDestroying &&
// sameDestroyTarget`); a click on the block a stopped break still names starts it again,
// so the server re-anchors its clock. A held button carries the same target on, and that
// one keeps the clock (#4124).
let running = self.remote.as_ref().is_some_and(|r| r.is_destroying);
if start && same && running {
return;
}
if !same || start {
// startDestroyBlock aborts only a break that is running; a target left by a stop that
// ended one is just replaced by the START below.
let aborted = self.remote.as_mut().and_then(|r| {
if !std::mem::take(&mut r.is_destroying) {
return None;
}
r.destroying.take()
});
if let Some((old, _, _)) = aborted {
let old_state = self.world.block(old[0], old[1], old[2]);
self.tutorial_destroy(old_state, -1.0);
self.send(S::PlayerAction(play::PlayerAction {
action: 2,
pos: old,
direction: face,
sequence: 0,
}));
}
let seq = self.next_sequence();
self.send(S::PlayerAction(play::PlayerAction {
action: 0,
pos: hit.pos,
direction: face,
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 {
self.predict_break(hit.pos, state, seq);
}
return;
}
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((hit.pos, face, 0.0));
r.destroying_item = held;
r.is_destroying = true;
}
// startDestroyBlock plays the first hit and crack itself; the
// server's level events 2019 and 2020 do the rest.
self.breaking_block_effects(hit.pos, hit.face, true);
self.mining = Some(Mining {
pos: hit.pos,
progress: 0.0,
ticks: 0,
});
return;
}
let Some((pos, last_face, done)) = self.remote.as_ref().and_then(|r| r.destroying) else {
return;
};
let done = done + progress;
self.tutorial_destroy(state, done.clamp(0.0, 1.0));
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
if done >= 1.0 {
let seq = self.next_sequence();
// DebugStickItem.canDestroyBlock: the server selects the property
// and sends the block update instead; never predict a break.
if !debug_stick {
self.predict_break(pos, state, seq);
}
self.send(S::PlayerAction(play::PlayerAction {
action: 3,
pos,
direction: face,
sequence: seq,
}));
if let Some(r) = self.remote.as_mut() {
// `destroyBlockPos` remains the last target after the break;
// if that position is filled again before the next pick, a
// held attack resumes it without sending a second START.
r.destroying = Some((pos, face, 0.0));
r.is_destroying = false;
r.destroy_delay = 5;
}
self.mining = None;
} else {
if last_face != face {
self.send(S::PlayerAction(play::PlayerAction {
action: 1,
pos,
direction: face,
sequence: 0,
}));
}
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((pos, face, done));
}
self.mining = Some(Mining {
pos,
progress: done,
ticks,
});
}
}
/// MultiPlayerGameMode.destroyBlock: the predicted break.
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId, sequence: i32) {
self.tutorial_destroy(state, 1.0);
// BlockStatePredictionHandler.retainKnownServerState.
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|p| p.0 = sequence)
.or_insert((sequence, state.0 as i32));
}
// Block.playerWillDestroy's destroy event (particles and the break
// sound), which BaseFireBlock skips: the server's extinguish sound
// is all a player hears.
let facts = self.res.facts().get(state);
if !facts.has_class("BaseFireBlock") {
let s = facts.sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&s.break_sound,
Some(at),
(s.volume + 1.0) / 2.0,
s.pitch * 0.8,
);
self.destroy_particles(pos, state);
}
// The server's block update follows (waterlogged blocks leave water).
let air = self.world.air;
if self.world.set(pos[0], pos[1], pos[2], air).is_ok() {
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
}
self.mining = None;
}
/// `Player.blockActionRestricted` as far as this client reads it: a
/// spectator never breaks, an adventure player only with a main-hand
/// item the server gave `can_break`. Whether its predicate matches the
/// block is left to the server, which sends the block back if not.
fn remote_break_restricted(&self) -> bool {
use horizon_protocol::item::{WirePatch, component::CAN_BREAK};
self.spectator()
|| (!self.may_build()
&& !self
.held()
.and_then(|s| s.wire_patch())
.is_some_and(|p| WirePatch::adds(&p, CAN_BREAK)))
}
/// Minecraft.startUseItem: use one crosshair target, then the item itself.
fn remote_use(&mut self) {
// Minecraft.startUseItem returns before resetting rightClickDelay
// while a survival block break is active.
if self.remote.as_ref().is_some_and(|r| r.is_destroying) {
return;
}
self.use_delay = 4;
// Minecraft.startUseItem: nothing is used while the hands paddle a boat.
if self.hands_busy() {
return;
}
// InputEvent.InteractionKeyMappingTriggered for the main hand: a cancel
// ends the whole click, the off hand gets no turn.
if let Some(swing) = self.native_interaction(1, Hand::Main) {
if swing {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
return;
}
// An item a mod claims: its use is the mod's, and the server never hears of it.
if self.mod_use_start() {
return;
}
// Resolve the crosshair once. A passing entity interaction keeps
// that target and may only fall back to using the item itself.
let entity = self.entity_targeted();
horizon_client::interaction_input::use_on_target(&mut RemoteUseWorld(self), entity);
}
/// `Minecraft.startUseItem`'s entity turn for `hand`: Interact goes to
/// the server, and whether the click ends there is what this client's
/// copy of the entity answers (`MultiPlayerGameMode.interact`).
fn remote_use_entity(&mut self, hand: Hand) -> bool {
// A menu this use opens (a chest minecart's) is no clicked block's.
if let Some(r) = self.remote.as_mut() {
r.use_target = None;
}
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
// A boat, minecart, item frame, armor stand or painting nearer than
// any mob: the server uses it (AbstractBoat.interact boards it).
if let Some(hit) = self.remote_object_hit(false)
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
// Interaction.interact answers SUCCESS when it responds and
// CONSUME otherwise: both end the click (nothing behind the box
// is used), but only SUCCESS swings; the packet carries the hand.
let response = self.interactions.get(&hit.id).map(|i| i.response);
// A cart whose interact passes (a TNT minecart) leaves each hand's
// turn to its item: a crossbow aimed at it shoots.
let passes = self
.carts
.iter()
.find(|c| c.id as i32 == hit.id)
.is_some_and(|c| {
let ridden = self.riding == Some(c.id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(c.id));
c.kind
.interact_passes(sneaking, ridden, self.can_use_game_master_blocks())
});
// Otherwise the main hand's turn takes it; there is no off hand turn.
if response.is_none() && hand == Hand::Off && !passes {
return false;
}
self.send(S::Interact(play::Interact {
id: hit.id,
hand: hand_id(hand),
location: hit.location,
secondary: sneaking,
}));
if passes {
return false;
}
// MinecartCommandBlock.interact on the client: the editor opens.
if self.carts.iter().any(|c| {
c.id as i32 == hit.id && c.kind == super::minecarts::CartKind::CommandBlock
}) {
self.open_cart_command_block(hit.id as u32);
}
if response.unwrap_or(true) {
self.swing_arm();
}
return true;
}
if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
let id = self.mobs[i].id as i32;
self.send(S::Interact(play::Interact {
id,
hand: hand_id(hand),
location: [0.0, 1.0, 0.0],
secondary: sneaking,
}));
// Player.interactOn: another player passes, and so does a mob
// the held item means nothing to: the item is used instead.
let player = self
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&id));
if !player && self.remote_mob_takes(i, hand) {
self.remote_feed_particles(i, hand);
self.with_hand(hand, Game::swing_arm);
return true;
}
}
false
}
/// `Minecraft.startUseItem`'s block turn for `hand`: a use that swung drops
/// the hand (`LocalPlayer.itemUsed`) when it spent the held item or the
/// player has infinite materials.
fn remote_use_block(&mut self, hand: Hand) -> bool {
let before = self.with_hand(hand, |g| g.held().map(|s| s.count));
let swings = self.swings_started;
let done = self.remote_use_item_on(hand);
if done
&& self.swings_started != swings
&& let Some(after) = self.with_hand(hand, |g| g.held().map(|s| s.count))
&& (Some(after) != before || self.creative)
{
self.heights.item_used(hand);
}
done
}
/// MultiPlayerGameMode.useItemOn for `hand`: whether the click ends there
/// (the block reacts, or a block item is placed).
fn remote_use_item_on(&mut self, hand: Hand) -> bool {
let held = self.with_hand(hand, |g| g.held().cloned());
let held_block = held
.as_ref()
.and_then(|h| self.items().get(&h.item))
.is_some_and(|i| i.block.is_some());
let debug_stick = held
.as_ref()
.is_some_and(|stack| &*stack.item == "minecraft:debug_stick");
if let Some(hit) = self.pick() {
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
// without predicting the inventory swap or swinging.
let shelf_reacts = hand == Hand::Main
&& held.is_some()
&& !(self.keys.contains(&KeyCode::ShiftLeft)
&& (self.stack_in(self.selected).is_some() || self.offhand.is_some()))
&& self.shelf_hit(&hit).is_some();
let spectator = self.spectator();
let (result, changes) = if hand == Hand::Main && !spectator {
self.remote_block_reacts(&hit, held.as_ref())
} else {
// useItemOn runs for the off hand too (useWithoutItem doesn't):
// RespawnAnchorBlock.useItemOn charges an anchor with its
// glowstone, SUCCESS.
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
let charges =
!(sneaking && hands_full) && self.anchor_takes(hit.pos, held.as_ref());
let result = if charges {
InteractionResult::Success
} else {
InteractionResult::Pass
};
(result, DryUse::default())
};
// DebugStickItem.useOn once the block passed: SUCCESS on the client
// too (the hand swings below); only the server changes the block.
let (result, changes) = if debug_stick && !spectator && !result.consumes_action() {
(InteractionResult::Success, DryUse::default())
} else {
(result, changes)
};
// AbstractCauldronBlock.useItemOn, either hand: CauldronInteractions
// answers on the client as on the server and only the server acts,
// so a bucket the cauldron takes is not used again (UseItem).
let result = if result.consumes_action() || spectator {
result
} else {
match self.cauldron_outcome(hit.pos, held.as_ref()) {
horizon_client::cauldron::Outcome::Success => InteractionResult::Success,
horizon_client::cauldron::Outcome::Consume => InteractionResult::Consume,
horizon_client::cauldron::Outcome::Pass => result,
}
};
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) or a lit
// candle cake (CandleCakeBlock.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 name = self.block_name(hit.pos);
let lit = self.pack.assets.registry.state(state).is_some_and(|s| {
s.properties.get("lit").map(String::as_str) == Some("true")
});
let snuffs = if name.ends_with("candle_cake") {
hit.point[1] - f64::from(hit.pos[1]) > 0.5
} else {
name.ends_with("candle") && self.may_build()
};
if held.is_none() && lit && snuffs {
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
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:armor_stand")
&& held
.as_ref()
.is_some_and(|s| self.armor_stand_spawn(&hit, s).is_some());
let seq = self.next_sequence();
let cursor = [
(hit.point[0] - f64::from(hit.pos[0])) as f32,
(hit.point[1] - f64::from(hit.pos[1])) as f32,
(hit.point[2] - f64::from(hit.pos[2])) as f32,
];
self.send(S::UseItemOn(play::UseItemOn {
hand: hand_id(hand),
pos: hit.pos,
face: i32::from(face_direction(hit.face)),
cursor,
inside: hit.face == [0; 3],
world_border: false,
sequence: seq,
}));
// MultiPlayerGameMode.performUseItemOn: a spectator's use predicts
// nothing and ends the click (CONSUME, no swing); the server opens
// the block's menu.
if spectator {
return true;
}
// Only a predicted placement (predict_place) marks a cell whose
// resend plays a place sound: an empty hand or a non-block item
// places nothing (BlockItem.place is where the sound comes from).
if let Some(r) = self.remote.as_mut() {
r.use_target = Some(hit.pos);
}
if shelf_reacts {
return true;
}
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]));
self.open_block_editor_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
// (a chest, barrel, furnace, …). CONSUME does not swing.
if result.should_swing() {
self.with_hand(hand, Game::swing_arm);
}
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
// CampfireBlock.useItemOn succeeded: this hand swings and the
// click ends, so a shield in the other hand is not raised.
self.with_hand(hand, Game::swing_arm);
return true;
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
// server brushes and the dust is this client's own.
self.with_hand(hand, Game::start_using_item);
return true;
} else if held
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:bone_meal")
&& self.remote_bone_meal_succeeds(&hit)
{
// BoneMealItem.useOn succeeded on this client's level (SUCCESS;
// the server grows the plant): this hand swings and the click
// ends, so the other hand (a torch) is never tried.
self.with_hand(hand, Game::swing_arm);
return true;
} else if stand_fits {
// ArmorStandItem.useOn on a client level: SUCCESS once the
// stand fits, before the server spawns it. This hand swings
// and the click ends.
self.with_hand(hand, Game::swing_arm);
return true;
} else if let Some(stack) = held.as_ref()
&& (self.predict_item_on_block(stack, &hit, hand)
|| self.predict_map_on_banner(stack, &hit))
{
// Item.useOn succeeded here (InteractionResult.SUCCESS):
// this hand swings and the click ends, so the other hand
// (a shield, a torch) is never tried.
self.with_hand(hand, Game::swing_arm);
return true;
}
if held_block {
let consumable = held
.as_ref()
.and_then(|stack| self.items().get(&stack.item).cloned())
.filter(|info| info.consumable.is_some());
// ItemStack.useOn passes in adventure mode unless the item may
// be placed on this block: useItem eats it (a block item without
// a consumable keeps ending the click here).
if !reacts
&& consumable.is_some()
&& !self.may_build()
&& !self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos))
{
return false;
}
let placed = if reacts {
None
} else {
held.as_ref()
.and_then(|stack| self.remote_can_place(&hit, stack))
};
// BlockItem.useOn: a consumable that could not be placed is used
// (Item.use), which the server does for this same packet. CONSUME
// does not swing, and a full player's FAIL still ends the click.
// Adventure's can_place_on leaves the placing to the server.
if !reacts
&& placed.is_none()
&& self.may_build()
&& let Some(info) = &consumable
{
self.with_hand(hand, |g| g.start_eating(info));
}
if let Some(placed) = &placed {
self.predict_place(placed, seq);
self.with_hand(hand, Game::consume_held);
} else if let Some(r) = self.remote.as_mut() {
// BlockItem.place plays its sound only once the block is set: the
// server's resend of the target it refused is no placement.
r.placing = None;
}
if placed.is_some() {
self.with_hand(hand, Game::swing_arm);
}
// BlockItem.place returns FAIL when placement is rejected:
// stop this click without trying the item or the other hand.
return true;
}
return reacts;
}
false
}
/// `Item.useOn` on this client's level for the items whose effects are
/// the clicker's own (`Level.levelEvent(player, ...)` and
/// `playSound(player, ...)`: the server sends them to the others only):
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
/// block (stripping, scraping, removing wax, tilling, making a path).
/// A cushion succeeds where it may be placed and spawns on the server, a
/// firework rocket wherever it may be used, and the server launches it.
/// Whether the item's use succeeded, which ends the click.
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit, hand: Hand) -> bool {
// FireworkRocketItem.useOn: SUCCESS on the client too, so neither
// UseItem nor the off hand follows. It passes while gliding (the use
// boosts instead), cooling down (MultiPlayerGameMode.performUseItemOn
// skips useOn) and in adventure mode off its can_place_on
// (ItemStack.useOn).
if &*stack.item == "minecraft:firework_rocket" {
let cooling = self.items().get(&stack.item).is_some_and(|info| {
let group = horizon_client::cooldowns::group(stack, info.use_cooldown.as_ref());
self.cooldowns.is_on_cooldown(&group)
});
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
return !cooling
&& !gliding
&& (self.may_build()
|| self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos)));
}
let at = [
f64::from(hit.pos[0]) + 0.5,
f64::from(hit.pos[1]) + 0.5,
f64::from(hit.pos[2]) + 0.5,
];
if &*stack.item == "minecraft:honeycomb" {
// HoneycombItem.useOn.
let registry = self.pack.assets.registry;
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let waxes = registry
.state(clicked)
.and_then(|old| self.res.transformers().waxed(registry, old))
.is_some();
if waxes {
self.level_event_at(3003, hit.pos, 0);
self.audio
.play("minecraft:item.honeycomb.wax_on", Some(at), 1.0, 1.0);
}
waxes
} else if let Some((_, sound, _, _, particle)) =
self.block_transform(stack, hit.pos, hit.face)
{
self.transform_particle(&particle, hit.pos);
self.audio.play(&sound, Some(at), 1.0, 1.0);
true
} else if self
.res
.items()
.get(&stack.item)
.is_some_and(|i| i.cushion_color.is_some())
{
// CushionItem.useOn on a client level: SUCCESS once
// `canBePlacedAt` holds, before the server's own checks.
self.cushion_spawn(hit).is_some()
} else if &*stack.item == "minecraft:painting" {
// HangingEntityItem.useOn on a client level: SUCCESS once
// `Painting.create` finds a surviving variant, before the
// server spawns it.
self.painting_fits(hit, stack)
} else {
false
}
}
/// `MapItem.useOn` on this client: a map used on a banner is SUCCESS
/// whatever its data says (the server toggles the marker, or fails
/// quietly), so this hand swings and the click ends. `ItemStack.useOn`
/// passes in adventure mode.
fn predict_map_on_banner(&self, stack: &ItemStack, hit: &super::Hit) -> bool {
super::maps::map_id(stack).is_some()
&& self.may_build()
&& self.block_in_tag("minecraft:banners", &self.block_name(hit.pos))
}
/// `BoneMealItem.useOn` on this client's level (a copy: nothing grows
/// here): whether it succeeds.
fn remote_bone_meal_succeeds(&mut self, hit: &super::Hit) -> bool {
let pos = horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
let face = horizon_core::geometry::Direction::ALL[face_direction(hit.face) as usize];
self.with_level(|b, level, _| {
b.bone_meal_succeeds_on_client(&mut DryLevel::new(level), pos, face)
})
}
/// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the
/// use changed on this client's level at once, each kept as predicted
/// under the use's sequence until the server acknowledges it (a use the
/// server refused goes back). Only the blocks a hand opens or flips: a
/// wooden or copper door, trapdoor or fence gate, a lever or a button;
/// whatever else the use changes waits for the server.
fn predict_use(&mut self, (changes, sounds): &DryUse, seq: i32) {
let facts = self.res.facts();
let predicted = |state: BlockStateId| hand_operated(&facts.get(state).name);
if changes.is_empty()
|| !changes
.iter()
.all(|(p, s)| predicted(*s) && predicted(self.world.block(p.x, p.y, p.z)))
{
return;
}
self.predict_blocks(changes, seq);
// `level.playSound(player, ...)`: the clicker hears it from its own use; the
// server sends it to the others only.
for (pos, sound, volume, pitch) in sounds {
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 0.5,
f64::from(pos.z) + 0.5,
];
let name = if sound.contains(':') {
sound.to_string()
} else {
format!("minecraft:{sound}")
};
self.audio.play(&name, Some(at), *volume, *pitch);
}
}
/// Whether the block hit takes a main-hand right click with `held`
/// (`BlockState.useItemOn`, then `useWithoutItem`): a door, a button, a
/// container, and the blocks it changed. Vanilla's client runs it on its
/// own level first; this runs it on a copy, and `predict_use` keeps the
/// changes it predicts.
fn remote_block_reacts(
&mut self,
hit: &super::Hit,
held: Option<&ItemStack>,
) -> (InteractionResult, DryUse) {
// Player.isSecondaryUseActive with something in either hand.
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
return (InteractionResult::Pass, DryUse::default());
}
// AbstractCauldronBlock.useItemOn (CauldronInteractions): a bucket,
// bottle or dye the cauldron takes fills or lowers it, and the click
// ends there instead of falling through to Item.use (which would
// empty a water bucket into the face of the cauldron).
if let Some(reacts) = self.remote_cauldron_reacts(hit, held) {
return reacts;
}
if self.jukebox_takes(hit.pos, held) {
return (InteractionResult::Success, DryUse::default());
}
// RespawnAnchorBlock.useItemOn: PASS when the off hand's glowstone is to
// charge the anchor, so useWithoutItem doesn't run and the off hand's turn
// comes (glowstone in this hand charges it in `use_item_on`).
if !self.anchor_takes(hit.pos, held) && self.anchor_takes(hit.pos, self.offhand.as_ref()) {
return (InteractionResult::Pass, DryUse::default());
}
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
// no UseItem follows (the server does the harvest, see `use_beehive`).
if held.is_some_and(|s| matches!(&*s.item, "minecraft:shears" | "minecraft:glass_bottle"))
&& self
.hive_state(hit.pos)
.is_some_and(|(honey, _)| honey == Some(5))
{
return (InteractionResult::Success, DryUse::default());
}
// ComposterBlock.useItemOn: a compostable item on a composter below
// level 8 is SUCCESS on the client too (only the server adds the
// layer), so the other hand (a torch) is never tried.
if self.composter_takes(hit.pos, held) {
return (InteractionResult::Success, DryUse::default());
}
use horizon_core::geometry::Direction;
let face = match hit.face {
[0, 1, 0] => Direction::Up,
[0, -1, 0] => Direction::Down,
[0, 0, -1] => Direction::North,
[0, 0, 1] => Direction::South,
[-1, 0, 0] => Direction::West,
_ => Direction::East,
};
let player = self.use_context();
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
let result = b.use_item_on(
&mut level,
&mut horizon_world::level::Ticks::default(),
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face,
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
player,
held.map(|s| &*s.item),
);
(result, (level.changed.into_iter().collect(), level.sounds))
})
}
/// `AbstractCauldronBlock.useItemOn` (`CauldronInteractions`) on this
/// client's level: the cauldron's new state and the sound it made, or
/// None when the item passes and `Item.use` still has its turn. The
/// interaction player is a copy: the hand and the inventory change only
/// when the server confirms them.
fn remote_cauldron_reacts(
&self,
hit: &super::Hit,
held: Option<&ItemStack>,
) -> Option<(InteractionResult, DryUse)> {
use horizon_client::cauldron::{self, Outcome};
struct DryHand<'a> {
creative: bool,
hand: Option<ItemStack>,
inventory: &'a [Option<ItemStack>],
}
impl cauldron::Player for DryHand<'_> {
fn creative(&self) -> bool {
self.creative
}
fn hand(&mut self) -> &mut Option<ItemStack> {
&mut self.hand
}
fn add(&mut self, _: &mut ItemStack) -> bool {
true
}
fn contains(&self, stack: &ItemStack) -> bool {
self.inventory
.iter()
.flatten()
.any(|have| have.same_item_same_components(stack))
}
fn drop_stack(&mut self, _: ItemStack) {}
}
let registry = self.pack.assets.registry;
let state = registry
.state(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.filter(|s| s.name.ends_with("cauldron"))?
.clone();
let held = held?;
let above = self.world.block(hit.pos[0], hit.pos[1] + 1, hit.pos[2]);
let under_water = registry
.state(above)
.is_some_and(|s| s.name == "minecraft:water");
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
let mut hand = DryHand {
creative: self.creative,
hand: Some(held.clone()),
inventory: &self.inventory,
};
let result = cauldron::interact(&state, under_water, removes_dye, &mut hand);
let reaction = match result.outcome {
Outcome::Pass => return None,
Outcome::Success => InteractionResult::Success,
Outcome::Consume => InteractionResult::Consume,
};
let pos = horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
let mut changes = Vec::new();
if let Some(block) = result.block
&& let Ok(new) = registry.resolve(&block)
{
changes.push((pos, new));
}
let sounds = match result.sound {
Some(sound) => vec![(pos, std::sync::Arc::from(sound), 1.0, 1.0)],
None => Vec::new(),
};
Some((reaction, (changes, sounds)))
}
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
/// level at once, so the next click of a fast bridge aims at them rather
/// than past them (the server would refuse it). Its place sound is the
/// placer's own (`playSound(player, ...)`: the server sends it to the
/// others only).
fn predict_place(&mut self, (pos, changes): &DryPlace, seq: i32) {
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
// set_block plays the place sound of the block at `placing`.
r.placing = Some(([pos.x, pos.y, pos.z], ticks));
}
self.predict_blocks(changes, seq);
}
/// The blocks a use changed, set on this client's level and kept as
/// predicted under the use's sequence until the server acknowledges it
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
/// server refused goes back.
pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|e| e.0 = seq)
.or_insert((seq, before.0 as i32));
}
self.set_block(pos, state.0 as i32);
}
}
/// Run BlockItem.place on a disposable level: where it placed and the
/// blocks it set. The real level supplies collision shapes and
/// building-blocking entities; random draws, scheduled ticks and events
/// stay in the preview.
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> Option<DryPlace> {
if !self.may_build() {
return None;
}
let ctx = horizon_world::level::PlaceContext {
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face: horizon_core::geometry::Direction::ALL[face_direction(hit.face) as usize],
hit: Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
inside: hit.face == [0; 3],
yaw: self.yaw,
pitch: self.pitch,
sneaking: self.keys.contains(&KeyCode::ShiftLeft),
item: stack.item.to_string(),
can_use_game_master_blocks: self.can_use_game_master_blocks(),
};
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
match b.place_block(
&mut level,
&mut horizon_world::level::Ticks::default(),
&ctx,
) {
horizon_world::level::PlaceOutcome::Placed { pos, .. } => {
Some((pos, level.changed.into_iter().collect()))
}
horizon_world::level::PlaceOutcome::Failed => None,
}
})
}
/// `MultiPlayerGameMode.useItem`'s local `ItemStack.use` for a bucket:
/// `BucketItem.use` picks its own target and runs `pickupBlock` or
/// `emptyContents` on this client's level inside `startPrediction`, so
/// its success (which decides `itemUsed`) and the blocks it changes are
/// the client's own. A fill into a waterloggable block changes nothing
/// here: `SimpleWaterloggedBlock.placeLiquid` tests `isClientSide` and
/// leaves the block update to the server.
fn remote_use_bucket(
&mut self,
stack: &ItemStack,
) -> (bool, Option<DryChanges>, Vec<DrySound>) {
// `BucketItem.use`: `level.mayInteract` and `mayUseItemAt`.
if !self.may_build() {
return (false, None, Vec::new());
}
if &*stack.item == "minecraft:bucket" {
let Some(hit) = self.bucket_hit() else {
return (false, None, Vec::new());
};
let pos = horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
let creative = self.creative;
return self.with_level(|b, level, ticks| {
let mut level = DryLevel::new(level);
let picked = b.bucket_pickup(&mut level, ticks, pos, creative).is_some();
(
picked,
Some(level.changed.into_iter().collect()),
std::mem::take(&mut level.sounds),
)
});
}
let Some((content, sound, _)) = horizon_client::items::bucket_contents(&stack.item) else {
return (false, None, Vec::new());
};
let Some(hit) = self.pick() else {
return (false, None, Vec::new());
};
let target = self.bucket_target(content, &hit);
let pos = horizon_core::position::BlockPos::new(target[0], target[1], target[2]);
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let creative = self.creative;
// A water bucket on a liquid container fills it server-side only.
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let client_change =
!(content.is_water() && self.res.behaviours().is_liquid_container(clicked));
self.with_level(|b, level, ticks| {
let mut level = DryLevel::new(level);
let emptied =
b.bucket_empty(&mut level, ticks, pos, content, sound, sneaking, creative);
(
emptied,
client_change.then(|| level.changed.into_iter().collect()),
std::mem::take(&mut level.sounds),
)
})
}
/// MultiPlayerGameMode.useItem for `hand`, if it holds something: whether
/// the click ends there, as far as this client can tell (`use_ends_click`).
fn remote_use_item(&mut self, hand: Hand) -> bool {
let Some(held) = self.with_hand(hand, |g| g.held().cloned()) else {
return false;
};
// A mod's claimed item is never used on the server; its own use
// starts from the main hand only (`mod_use_start`).
if self.native_input.claimant(&held.item).is_some() {
return false;
}
let seq = self.next_sequence();
self.send(S::UseItem(play::UseItem {
hand: hand_id(hand),
sequence: seq,
yaw: self.yaw,
pitch: self.pitch,
}));
let Some(info) = self.items().get(&held.item).cloned() else {
return false;
};
// MultiPlayerGameMode.useItem's prediction: an item cooling down
// passes (its packet still goes).
let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref());
if self.cooldowns.is_on_cooldown(&group) {
return false;
}
// a mod's item used over time starts here too, as the server starts it.
if self.with_hand(hand, |g| g.start_mod_use(&held)) {
return true;
}
// Item.use: Equippable.swapWithEquipmentSlot runs on this client too, so
// the armor slot and the hand change at once (the server confirms).
if info.equip_slot.is_some() && self.with_hand(hand, |g| g.equip_from_hand(&held)) {
// Equippable.swapWithEquipmentSlot's SUCCESS (SwingSource.CLIENT).
self.with_hand(hand, Game::swing_arm);
return true;
}
// `ItemStack.use` runs on this client too: a bucket's own ray and its
// pick/empty contents decide whether the click succeeds (a failed
// fill or pickup leaves the hand alone), and the blocks it sets are
// this use's prediction.
if &*held.item == "minecraft:bucket"
|| horizon_client::items::bucket_contents(&held.item).is_some()
{
let (succeeded, changes, sounds) = self.with_hand(hand, |g| g.remote_use_bucket(&held));
if let Some(changes) = changes {
self.predict_blocks(&changes, seq);
}
// `BucketItem.emptyContents` / evaporate: `playSound(player, …)` on
// this client; the server leaves the actor out (#4684).
for (pos, sound, volume, pitch) in sounds {
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 0.5,
f64::from(pos.z) + 0.5,
];
let name = if sound.contains(':') {
sound.to_string()
} else {
format!("minecraft:{sound}")
};
self.audio.play(&name, Some(at), volume, pitch);
}
// BucketItem.use's SUCCESS (SwingSource.CLIENT) on this client.
if succeeded {
self.with_hand(hand, Game::swing_arm);
}
return succeeded;
}
// SpawnEggItem.use on the client succeeds after a SOURCE_ONLY block
// hit; the server alone tests LiquidBlock and the placement permission.
if held.item.ends_with("_spawn_egg") {
let succeeded = self.spawn_egg_allowed(&held.item) && self.bucket_hit().is_some();
if succeeded {
self.with_hand(hand, Game::swing_arm);
}
return succeeded;
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
// (InteractionResult.FAIL): startUseItem tries the other hand, and
// no cooldown or use animation starts.
let eating = self.with_hand(hand, |g| g.start_eating(&info));
if info.food.is_some() && !eating {
return false;
}
// A bucket, a glass bottle or a boat with nothing to fill, empty or
// place PASSes or FAILs: startUseItem goes on to the off hand (a
// Riptide trident there charges).
if self.with_hand(hand, |g| g.use_finds_no_target(&held)) {
return false;
}
// It runs the use here too: ItemStack.use starts the `use_cooldown`
// of an item with no use time, and the server's
// ClientboundCooldownPacket confirms it; one held in use starts it at
// finishUsingItem (remote_complete_using).
if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) {
self.apply_use_cooldown(&held);
}
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
// WritableBookItem.use's SUCCESS (SwingSource.CLIENT).
self.with_hand(hand, Game::swing_arm);
return true;
}
// BowItem.use and CrossbowItem.use FAIL with nothing to shoot: no
// itemUsed (the hand doesn't dip) and the other hand's turn comes.
if !self.with_hand(hand, |g| g.remote_start_held_use(&held)) {
return false;
}
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
// FireworkRocketItem.use while gliding: SUCCESS (SwingSource.PREDICTED),
// so startUseItem swings this hand itself; a vanilla server swings it
// for the other players only.
if gliding && &*held.item == "minecraft:firework_rocket" {
self.with_hand(hand, Game::swing_arm);
}
// A stack's own `blocks_attacks` raises it as a shield's does.
let blocks = self.blocks_attacks(&held).is_some();
let ends = use_ends_click(&info, gliding) || blocks;
// MultiPlayerGameMode.useItem: `ItemStack.use`'s SUCCESS swings
// (SwingSource.CLIENT); CONSUME does not — a bow, crossbow, trident
// or spear starting its charge, and `ItemUtils.startUsingInstantly`'s
// food, drink, spyglass, shield and goat horn.
if ends && !blocks && remote_use_swings(&held, info.consumable.is_some()) {
self.with_hand(hand, Game::swing_arm);
}
ends
}
/// Whether `held`'s `Item.use` needs a target this client sees none of
/// (not SUCCESS): BucketItem.use (a fluid source or powder snow to pick
/// up through `ClipContext.Fluid.SOURCE_ONLY`, a block to empty a full
/// one against, `mayUseItemAt`), BottleItem.use (water to fill from) and
/// BoatItem.use (a block or fluid surface with room for the boat).
fn use_finds_no_target(&self, held: &ItemStack) -> bool {
let id = &*held.item;
let facts = |pos: [i32; 3]| {
self.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
};
let source = |want: Option<Fluid>| {
let fluid_hit = self.pick_fluid();
// pick_fluid steps through waterlogged blocks, but their water is
// a source the SOURCE_ONLY ray stops at (and a BucketPickup).
if let Some(hit) = self.pick()
&& facts(hit.pos).waterlogged
{
let eye = self.aim().map_or(hit.point, |(eye, _)| eye);
let d = |p: [f64; 3]| (0..3).map(|a| (p[a] - eye[a]).powi(2)).sum::<f64>();
if fluid_hit
.as_ref()
.is_none_or(|fluid| d(fluid.point) >= d(hit.point))
{
return want.is_none_or(|want| want == Fluid::Water);
}
}
fluid_hit.is_some_and(|hit| {
let f = facts(hit.pos);
let fluid = f.fluid != Fluid::None && f.fluid_level == 8 && !f.fluid_falling;
match want {
Some(want) => fluid && f.fluid == want,
None => fluid || self.is_powder_snow(hit.pos),
}
})
};
// SolidBucketItem only has useOn: its use is Item.use's PASS.
if id == "minecraft:powder_snow_bucket" {
return true;
}
if id == "minecraft:bucket" {
return !self.may_build() || !source(None);
}
if horizon_client::items::bucket_contents(id).is_some() {
return !self.may_build() || self.pick().is_none();
}
if id == "minecraft:glass_bottle" {
return !source(Some(Fluid::Water));
}
if super::boats::any_boat_wood(id).is_some() {
return self.boat_to_place(held).is_none();
}
false
}
/// `Item.use` runs on the client too (MultiPlayerGameMode.useItem): an
/// item held in use until the button lets go starts being used here, so
/// that letting go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and
/// the server ends the use: a bow or a trident is let go, a crossbow's
/// charge ends, a shield is lowered, a spyglass stops scoping. Their
/// sounds, loaded arrows and thrown tridents come from the server.
/// False when the use FAILs here: a bow or an uncharged crossbow with
/// nothing to shoot.
fn remote_start_held_use(&mut self, held: &ItemStack) -> bool {
if self.using.is_some() {
return true;
}
let bundle = self.items().get(&held.item).is_some_and(|i| i.bundle);
let start = match &*held.item {
// BundleItem.use: always starts; the server drops the contents.
_ if bundle => true,
// BowItem.use: with an arrow, or in creative.
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
// CrossbowItem.use: charging when not charged and with ammunition;
// a charged one shoots on the server.
"minecraft:crossbow" => {
if held.data().is_some_and(|d| !d.charged.is_empty()) {
return true;
}
self.creative || self.projectile_slot("minecraft:crossbow").is_some()
}
// TridentItem.use: not about to break, Riptide only in water or rain.
"minecraft:trident" => {
self.use_trident(held);
return true;
}
// SpyglassItem.use (its sound is the server's).
"minecraft:spyglass" => true,
// InstrumentItem.use: the horn plays here too (`playSound(player, ...)`
// skips the player's own client), the server's copy tells the others.
"minecraft:goat_horn" => {
self.remote_use_instrument(held);
return true;
}
// EnderEyeItem.use: startUsingItem unless an End portal frame is
// aimed at, with or without a stronghold to throw the eye at.
"minecraft:ender_eye" => !self.aims_at_end_portal_frame(),
// Item.use: a kinetic weapon starts being used.
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
// ShieldItem.use: anything that blocks attacks, off cooldown.
_ => {
self.raise_shield(self.held_slot());
return true;
}
};
if start {
self.start_using_item();
}
start || !matches!(&*held.item, "minecraft:bow" | "minecraft:crossbow")
}
/// `InstrumentItem.use` on this client: unless the horn is on cooldown,
/// it is held up, plays its sound at the player and starts its cooldown.
fn remote_use_instrument(&mut self, held: &ItemStack) {
let Some(instrument) = self.res.items().instrument(held) else {
return;
};
let group = horizon_client::cooldowns::group(held, None);
if self.cooldowns.is_on_cooldown(&group) {
return;
}
self.start_using_item();
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
if instrument.use_duration > 0.0 {
self.add_cooldown(group, instrument.use_ticks());
}
}
/// Whether `stack` stays in use until the button lets go (bows,
/// crossbows, tridents, spyglasses, shields, eyes of ender, whose
/// `getUseDuration` 0 never counts down to the end) or the server ends
/// it (a mod's item): its use only counts ticks here.
fn held_until_released(&self, stack: &ItemStack) -> bool {
if self.mod_use(stack) {
return true;
}
matches!(
&*stack.item,
"minecraft:bow"
| "minecraft:crossbow"
| "minecraft:trident"
| "minecraft:spyglass"
| "minecraft:ender_eye"
) || horizon_client::spear::Kinetic::of(&stack.item).is_some()
|| self.blocks_attacks(stack).is_some()
}
/// The rest of Minecraft.startUseItem once the main hand's target passed:
/// the main hand's item, then the off hand's turn (its block, its item).
/// The server does each packet's part with that packet's hand only.
fn remote_use_hands(&mut self) {
// A successful Item.use always drops the hand (LocalPlayer.itemUsed).
if self.remote_use_item(Hand::Main) {
self.heights.item_used(Hand::Main);
return;
}
// The off hand's turn: the event again, then the entity or the block.
if let Some(swing) = self.native_interaction(1, Hand::Off) {
if swing {
self.swing_hand(Hand::Off, SwingAnimation::DEFAULT);
}
return;
}
let off_target = if self.entity_targeted() {
self.remote_use_entity(Hand::Off)
} else {
self.remote_use_block(Hand::Off)
};
if off_target {
return;
}
if self.remote_use_item(Hand::Off) {
self.heights.item_used(Hand::Off);
}
}
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
/// use (`held_until_released`), and a consumable's eating animation and
/// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the
/// stack, its hunger and the sounds.
fn remote_tick_using(&mut self) {
let Some((slot, ticks)) = self.using else {
// A mod's use the game reset (the server stopping the use...).
self.end_mod_use();
return;
};
let stack = self.stack_in(slot).cloned();
// The use stops when the hand no longer holds the item it began with
// (ItemStack.isSameItem).
let item = stack.as_ref().map(|s| Arc::clone(&s.item));
if let Some(r) = self.remote.as_mut() {
if ticks == 0 {
r.use_item = item;
} else if r.use_item != item {
self.using = None;
self.end_mod_use();
return;
}
}
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// A mod's item: held until the button lets go, counted for the mod.
if self.mod_use_tick(slot, ticks, !self.right || !in_hand) {
return;
}
// updatingUsingItem runs here too: a use a mod's tick ran out stays
// frozen until let go (RELEASE_USE_ITEM) or the server finishes it.
if let Some(s) = stack.as_ref().filter(|_| in_hand)
&& !self.use_item_ticked(s, ticks)
{
return;
}
// BrushItem.onUseTick: with no block aimed the use ends (the server
// ends it too); a stroke shows its dust. Its sounds are the server's.
if stack
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:brush")
{
let elapsed = ticks + 1;
let hit = self.pick().filter(|_| in_hand);
if let Some(hit) = &hit
&& elapsed % 10 == 5
{
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
}
if hit.is_none() && in_hand {
self.send(S::PlayerAction(play::PlayerAction {
action: 6,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
}
self.using = hit.and(Some((slot, elapsed))).filter(|_| elapsed < 200);
return;
}
// BundleItem.getUseDuration: 200 ticks, the server drops the contents.
if stack
.as_ref()
.is_some_and(|s| self.items().get(&s.item).is_some_and(|i| i.bundle))
{
self.using = (in_hand && ticks + 1 < 200).then_some((slot, ticks + 1));
return;
}
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go.
if let Some(s) = stack.as_ref().filter(|s| self.held_until_released(s)) {
if in_hand && (self.mod_use(s) || self.blocks_attacks(s).is_some()) {
self.item_on_use_tick(s, ticks);
}
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
// A goat horn is held up for its instrument's use duration.
if let Some(duration) = stack
.as_ref()
.and_then(|s| self.res.items().instrument(s))
.map(|i| i.use_ticks().max(0) as u32)
{
self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1));
return;
}
let consumable = stack
.as_ref()
.and_then(|s| self.items().get(&s.item))
.and_then(|i| i.consumable.clone());
let (Some(stack), Some(consumable)) = (stack, consumable) else {
self.using = None;
return;
};
// Button release belongs to the next handleKeybinds phase, which
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
// quick press/release between ticks.
if !in_hand {
self.using = None;
return;
}
// Consumable.startConsuming eats one with no use time at once, without
// a use (nor a USE_ITEM_COMPLETE).
if consumable.ticks() <= 0 {
self.using = None;
return;
}
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
// countdown goes on past zero (LivingEntity.updateUsingItem completes
// only on the server) until the server's USE_ITEM_COMPLETE.
// getUseItemRemainingTicks: the duration this use started with.
let remaining = self.use_duration - ticks as i32;
if consumable.should_emit(remaining) {
// `emit_consume` plays the eat sound and spawns the bites (when the
// item has consume particles), as `tick_used_item` does on the
// player's own server.
self.emit_consume(&consumable, &stack, 5);
}
self.item_on_use_tick(&stack, ticks);
self.using = Some((slot, ticks + 1));
}
/// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume,
/// which runs on the client too (Consumable.onConsume calls its
/// ConsumableListeners on both sides): the client's FoodData eats at once,
/// so a held right click finds `Player.canEat` false instead of starting
/// another bite before the server's ClientboundSetHealth arrives.
fn remote_complete_using(&mut self) {
let Some((slot, _)) = self.using.take() else {
return;
};
let Some(stack) = self.stack_in(slot).cloned() else {
return;
};
let Some(info) = self.items().get(&stack.item).cloned() else {
return;
};
// Consumable.onConsume's emitParticlesAndSounds(16): the last crumbs
// and bite, which the server plays for the others only.
if let Some(consumable) = &info.consumable {
self.emit_consume(consumable, &stack, 16);
}
if let Some((nutrition, saturation)) = info.food
&& let Some(r) = self.remote.as_mut()
{
// FoodData.eat(nutrition, saturation), as finish_consuming does;
// the next ClientboundSetHealth replaces it.
let (_, food, sat) = &mut r.health;
*food = (*food + nutrition as i32).clamp(0, 20);
*sat = (*sat + saturation).clamp(0.0, *food as f32);
self.apply_remote_health();
}
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
}
/// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick.
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
use horizon_client::interaction_input::Button;
let input = match button {
MouseButton::Left => {
self.toggles
.button(2, pressed, false, self.settings.toggle_attack);
self.left = self.toggles.is_down(2);
Some(Button::Attack)
}
MouseButton::Right => {
self.toggles
.button(3, pressed, false, self.settings.toggle_use);
self.right = self.toggles.is_down(3);
Some(Button::Use)
}
MouseButton::Middle => Some(Button::Pick),
_ => None,
};
if pressed
&& let Some(button) = input
&& let Some(remote) = self.remote.as_mut()
{
remote.input.press(button);
}
Ok(())
}
pub(super) fn request_remote_mining_stop(&mut self) {
if let Some(r) = self.remote.as_mut() {
r.stop_mining = true;
}
}
fn remote_handle_input(&mut self) {
let Some(r) = self.remote.as_mut() else {
return;
};
let mut input = std::mem::take(&mut r.input);
let stop = std::mem::take(&mut r.stop_mining);
let ready = r.loaded_sent
&& self.screen == Screen::Playing
&& self.sleeping.is_none()
&& !self.dialog_open();
if stop || !ready {
self.stop_remote_mining();
}
if !ready {
return;
}
// MultiPlayerGameMode.tick syncs the carried slot before keybinds.
let carried_changed = self.remote.as_mut().is_some_and(|r| {
if r.carried == Some(self.selected) {
false
} else {
r.carried = Some(self.selected);
true
}
});
if carried_changed {
self.send(S::SetCarriedItem(play::SetCarriedItem {
slot: self.selected as i16,
}));
}
let (attack, use_item) = (self.left, self.right);
input.tick(&mut RemoteInputWorld(self), attack, use_item);
// handleKeybinds' keybind polls: the queued presses act now, in tick
// order, so no packet goes out between CLIENT_TICK_END and a pong.
let pending = self
.remote
.as_mut()
.map(|r| std::mem::take(&mut r.pending_keys))
.unwrap_or_default();
for key in pending {
match key {
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
KeyCode::KeyQ if !self.spectator() => {
// Minecraft.hasControlDown: the physical keys, so a toggled
// sprint on Left Control does not make Q drop the stack.
let all = self.has_control_down();
self.remote_drop(all);
}
KeyCode::KeyE if self.riding_mount_with_menu() || self.riding_chest_boat() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
}
_ => {}
}
}
if self.clicks.miss_time > 0 {
self.clicks.miss_time -= 1;
}
if let Some(r) = self.remote.as_mut() {
r.input = input;
}
}
fn remote_start_attack(&mut self) -> bool {
if self.clicks.miss_time > 0 {
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;
}
let spear = self
.held()
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item));
if spear.is_some() && self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
return false;
}
// InputEvent.InteractionKeyMappingTriggered, after the checks that stop
// an attack: a cancel ends it there, swinging unless the mod said not to.
if let Some(swing) = self.native_interaction(0, Hand::Main) {
if swing {
let animation = spear.map_or(SwingAnimation::DEFAULT, |k| k.attack_animation());
self.swing_hand(Hand::Main, animation);
}
return false;
}
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
// stabs, this client swings, plays the sound and starts its charge
// again.
if let Some(kinetic) = spear {
// ServerboundPlayerActionPacket.Action.STAB.
self.send(S::PlayerAction(play::PlayerAction {
action: 8,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
self.hand_strength.reset_attack();
self.swing_hand(Hand::Main, kinetic.attack_animation());
self.play_at_feet(kinetic.attack_sound);
return true;
}
let mut instant = false;
// A ghast's fireball or a wind charge nearer than any mob: the
// server turns it (game/deflect.rs).
if let Some((i, distance)) = self.redirectable_hit()
&& self.mob_hit().is_none_or(|(_, d)| distance <= d)
&& self.before_block(distance)
&& self.creatures.projectiles[i].id != 0
{
self.send(S::Attack(play::Attack {
id: self.creatures.projectiles[i].id as i32,
}));
self.reset_attack_strength();
} else if let Some(hit) = self.remote_object_hit(true)
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
// a painting broken or an EndCrystal hurtServer: the server decides.
self.send(S::Attack(play::Attack { id: hit.id }));
self.reset_attack_strength();
} else if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
self.send(S::Attack(play::Attack {
id: self.mobs[i].id as i32,
}));
self.remote_player_attack(i);
self.reset_attack_strength();
} else if !self.entity_targeted()
&& let Some(hit) = self.pick()
{
self.remote_mining(true);
instant = self
.res
.facts()
.is_air(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]));
} else {
if !self.creative {
self.clicks.miss_time = 10;
}
self.reset_attack_strength();
}
self.swing_arm();
self.send(S::Punch(Empty));
instant
}
/// `MultiPlayerGameMode.attack` runs `Player.attack` on this client too.
/// For a mob `hurtOrSimulate` fails here (`Entity.hurtClient`), so the
/// hit has no attacker side; but `RemotePlayer.hurtClient` is true, so a
/// hit on another player with extra knockback runs the attacker's side of
/// `causeExtraKnockback`: the 0.6 horizontal speed cut and the sprint
/// stop. Without it the next move outruns the server's player.
fn remote_player_attack(&mut self, mob: usize) {
// Java dispatches this through the target's RemotePlayer type. The
// level_players map is profile bookkeeping and can lag the entity;
// mobs must stay out because their hurtClient returns false.
if self.mobs[mob].kind.id != "minecraft:player" {
return;
}
// `getAttackStrengthScale(0.5)`: here the ticker is `hand_strength`'s
// (`attack_ticks` only counts in the local world), and the attack
// speed is the player's synced attribute, which a server may change.
let speed = self
.own_attribute("minecraft:attack_speed")
.unwrap_or_else(|| self.main_hand_attack_speed());
let strength = self.hand_strength.scale(0.5, speed);
// Player.attack's damage: a riptide spin's, or this client's own
// ATTACK_DAMAGE, which the held item and effects don't change here.
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
let damage = if spin > 0.0 {
spin
} else {
self.own_attribute("minecraft:attack_damage")
.map_or(1.0, |v| v as f32)
};
let knockback = self
.own_attribute("minecraft:attack_knockback")
.map_or(0.0, |v| v as f32);
let sprinting = self.player.as_ref().is_some_and(|p| p.sprinting);
if !horizon_client::attack_strength::attacker_slows_after_hit(
damage, knockback, strength, sprinting,
) {
return;
}
if let Some(p) = self.player.as_mut() {
let slowdown = horizon_client::attack_strength::ATTACKER_SLOWDOWN;
p.velocity = Vec3::new(
p.velocity.x * slowdown,
p.velocity.y,
p.velocity.z * slowdown,
);
}
self.stop_sprint_after_extra_knockback();
}
/// An attribute's value on this client's player, if its map has the attribute.
fn own_attribute(&mut self, id: &str) -> Option<f64> {
let p = self.player.as_mut()?;
let key = p.attributes.key(id)?;
p.attributes.lookup(Some(&key))?;
p.attributes
.value(Some(key), horizon_world::attribute_map::ValueKind::Computed)
.ok()
}
fn remote_continue_attack(&mut self, down: bool) {
if !down {
self.clicks.miss_time = 0;
}
// A piercing weapon neither breaks blocks nor swings on its own.
if self.clicks.miss_time > 0
|| self.using.is_some()
|| self
.held()
.is_some_and(|s| horizon_client::spear::Kinetic::of(&s.item).is_some())
{
return;
}
let entity = self.entity_targeted();
if down
&& !entity
&& let Some(hit) = self.pick()
{
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let air = self.res.facts().is_air(state);
// InputEvent.InteractionKeyMappingTriggered, before the dig goes on:
// a cancel digs nothing and swings unless the mod said not to.
let canceled = if air {
None
} else {
self.native_interaction(0, Hand::Main)
};
if let Some(swing) = canceled {
if swing {
self.swing_arm();
self.send(S::Punch(Empty));
}
} else if self.remote_break_restricted() {
// continueDestroyBlock returned false: no swing.
} else if !air {
self.remote_mining(false);
self.swing_arm();
self.send(S::Punch(Empty));
} else {
if let Some(r) = self.remote.as_mut() {
r.destroying = None;
r.is_destroying = false;
}
self.mining = None;
}
} else {
// `stopDestroyBlock` returns at once for a break that is not running: a pick that
// missed once a break stopped ends nothing, so the dig's accumulated progress stays
// and goes on growing on the jumps that follow (#4124).
if self.stop_remote_mining() {
self.mining = None;
}
}
}
/// Keys with a multiplayer meaning; returns true when consumed.
pub(super) fn remote_key(&mut self, key: KeyCode) -> Result<bool> {
if self.screen != Screen::Playing {
if (key == KeyCode::KeyE || key == KeyCode::Escape) && self.screen == Screen::Inventory
{
self.remote_close_container();
self.screen = Screen::Playing;
self.capture(true);
return Ok(true);
}
return Ok(false);
}
match key {
KeyCode::KeyT | KeyCode::Slash | KeyCode::Enter => {
// ChatScreen, over the server's command tree.
self.open_chat_method(if key == KeyCode::Slash { "/" } else { "" });
// `Minecraft.handleKeybinds` opens it in the call that then
// releases an active use (Gui.handleKeybinds runs before the
// isUsingItem branch).
self.release_use_for_keybind_screen();
Ok(true)
}
KeyCode::KeyQ => {
if self.spectator() {
return Ok(true);
}
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
Ok(true)
}
// Open the ridden vehicle's own screen (`HasCustomInventoryScreen`)
// instead of the player's inventory: riding a horse, llama or
// chest boat, the inventory key sends
// ServerboundPlayerCommandPacket OPEN_INVENTORY.
KeyCode::KeyE if self.riding_mount_with_menu() || self.riding_chest_boat() => {
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
Ok(true)
}
KeyCode::KeyE => {
// The inventory opens without telling the server.
if self.tutorial.open_inventory() {
self.persist_tutorial_step();
}
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 {
self.screen = Screen::Inventory;
}
self.craft_size = 2;
self.capture(false);
// `Minecraft.handleKeybinds` opens the inventory in the same
// call that then releases an active use.
self.release_use_for_keybind_screen();
Ok(true)
}
_ => Ok(false),
}
}
/// `Minecraft.handleKeybinds`: a key that opens a screen runs
/// `Gui.setScreen`, whose `KeyMapping.releaseAll` clears the use key, and
/// the same invocation then releases an active use
/// (`player.isUsingItem() && !keyUse.isDown()` → `releaseUsingItem`).
/// Only the screens opened from those keybinds do this (inventory, chat,
/// advancements); the pause menu, server containers, mount inventories and
/// dialogs release on the first tick after closing instead, as vanilla
/// does.
pub(super) fn release_use_for_keybind_screen(&mut self) {
// Only a server's client holds a use to release.
if self.remote.is_none() {
return;
}
// `KeyMapping.releaseAll` clears key.use; without this the button still
// reads held and a use would start again on the first tick back.
self.right = false;
horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld(self));
}
/// `MultiPlayerGameMode.releaseUsingItem`: sends `RELEASE_USE_ITEM` and
/// runs the client half of `Item.releaseUsing` (a Riptide launch moves the
/// player itself). Vanilla reaches it both when the use key is let go and,
/// in the same tick, when a keybind opens a screen.
fn release_remote_use(&mut self) {
self.send(S::PlayerAction(play::PlayerAction {
action: 6,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
// a mod's Stop may skip the item's release, and a mod's own release follows.
let hand = self.used_hand();
if let Some((slot, ticks)) = self.using.take()
&& let Some(hand) = hand
&& let Some(stack) = self.stack_in(slot).cloned()
{
self.with_hand(hand, |g| {
if !g.use_item_stopped(&stack, ticks) {
return;
}
if &*stack.item == "minecraft:trident" {
g.release_trident(g.ticks_held(&stack, ticks));
}
g.mod_release_using(&stack, ticks);
});
}
}
/// ClientPacketListener.sendChat / sendCommand, signed with a chat
/// session (game/remote_chat.rs). The Chat option's Hidden and System
/// levels keep chat out and Hidden keeps the commands out (ChatAbilities).
pub(super) fn send_chat(&mut self, message: &str) {
let message = message.trim();
if message.is_empty() || !self.chat_abilities().allows(message) {
return;
}
if let Some(command) = message.strip_prefix('/') {
// The player's own RAM limit, which no server has a say in.
if !self.ram_limit_command(command) {
self.send_command(command);
}
} else {
self.send_signed_chat(message);
}
}
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
/// the action; swing only if a nonempty stack was removed.
pub(super) fn remote_drop(&mut self, all: bool) {
let dropped =
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
}
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
/// sends both slots back; nothing is predicted.
pub(super) fn remote_swap_hands(&mut self) {
self.send(S::PlayerAction(play::PlayerAction {
action: 7,
pos: [0; 3],
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) {
let Some(size) = self
.renderer
.as_ref()
.map(|r| [r.config.width, r.config.height])
else {
return;
};
let batch = HudBatch::new(size);
let Some(internal) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
return;
};
let input = if self.has_shift_down() {
Input::QuickMove
} else {
Input::Pickup
};
if self.screen == Screen::Creative {
// CreativeModeInventoryScreen.slotClicked: the Survival Inventory tab clicks the
// player's own InventoryMenu, and its CreativeInventoryListener sends each slot that
// changed (SetCreativeModeSlot). No ContainerClick: a server holding the carried
// stack would give it back after the Destroy Item slot cleared it (#3307).
self.menu_click(Some(internal), i32::from(right), input);
return;
}
self.remote_menu_ops(&[(Some(internal), i32::from(right), input)]);
}
/// `AbstractContainerMenu.setItem`/`initializeContents`: the state id a
/// click answers with is the open menu's, not another window's.
fn remote_menu_state(&mut self, container: i32, state: i32) {
let open = self.remote_menu().0;
if let Some(r) = self.remote.as_mut().filter(|_| container == open) {
r.state_id = state;
}
}
/// The server's open menu: its window id and menu type (0, "inventory"
/// when none is open).
pub(super) fn remote_menu(&self) -> (i32, String) {
match self.remote.as_ref().and_then(|r| r.menu.clone()) {
Some((id, name)) => (id, name),
None => (0, "inventory".to_owned()),
}
}
/// `MultiPlayerGameMode.handleContainerInput` for each click of `ops` on
/// the server's open menu (a quick craft drag is several): the verified
/// menu engine predicts them on this client's copy, then each goes out as
/// a `ContainerClick` with the slots it changed and the carried stack
/// after it; the server corrects anything it sees differently. A menu the
/// engine doesn't model sends the clicks unpredicted. False when a click's
/// slot isn't one of the menu's.
pub(super) fn remote_menu_ops(&mut self, ops: &[(Option<usize>, i32, Input)]) -> bool {
// CrafterScreen.slotClicked: a slot toggle goes out before the click.
if let [(Some(slot), button, input)] = ops {
self.crafter_slot_clicked(*slot, *button, *input);
}
let (container, menu) = self.remote_menu();
let mut clicks = Vec::with_capacity(ops.len());
for &(slot, button, input) in ops {
let slot = match slot {
None => -999,
Some(internal) => match Self::menu_slot_index(&menu, internal) {
Some(s) => s as i16,
None => return false,
},
};
clicks.push((slot, button, input));
}
// Unpredicted: nothing changed, the carried stack as it is.
let mut sent: Vec<Predicted> = clicks
.iter()
.map(|&(slot, button, input)| (slot, button, input, Vec::new(), self.carried.clone()))
.collect();
let mut threw = false;
// The engine's slot indices are the menu's own, as on the network.
self.menu_session(|m, env, _| {
let Some((predicted, dropped)) = predict_clicks(m, env, &clicks) else {
// The server throws on it too: nothing is predicted.
return Some(false);
};
sent = predicted;
threw = dropped;
Some(true)
});
self.send_clicks(container, sent);
// LivingEntity.drop(.., Prediction.PREDICTED): the client swings its own
// main hand; the server shows the swing only to the other players.
if threw {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
true
}
/// A Shift double click on a server (`AbstractContainerScreen.mouseReleased`):
/// a predicted QUICK_MOVE for every slot the engine moves, each sent.
pub(super) fn remote_quick_move_all(&mut self, slot: usize, moved: &ItemStack) {
let (container, _) = self.remote_menu();
let mut sent: Vec<Predicted> = Vec::new();
self.menu_session(|m, env, to_menu| {
let clicked = to_menu(slot)?;
let mut before = menu_items(m);
let mut each = |m: &horizon_client::menu::Menu, t: usize| {
let changed = changed_slots(&before, m);
sent.push((t as i16, 0, Input::QuickMove, changed, m.carried.clone()));
before = menu_items(m);
};
Some(
m.quick_move_all_each(env, clicked, moved, &mut each)
.is_ok(),
)
});
self.send_clicks(container, sent);
}
fn send_clicks(&mut self, container: i32, clicks: Vec<Predicted>) {
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
let hashed = |g: &Self, s: &Option<ItemStack>| g.hashed_stack(s);
for (slot, button, input, changed, carried) in clicks {
let changed = changed.iter().map(|(i, s)| (*i, hashed(self, s))).collect();
let carried = hashed(self, &carried);
self.send(S::ContainerClick(play::ContainerClick {
container,
state,
slot,
button: button as i8,
input: input_ordinal(input),
changed,
carried,
}));
}
}
/// The server's open menu is the inventory or a chest-like container
/// (chests, barrels, ender chests: `generic_9xN`; shulker boxes).
pub(super) fn remote_storage_open(&self) -> bool {
match self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
None => true,
Some((_, name)) => {
name == "inventory"
|| name.starts_with("minecraft:generic_9x")
|| name == "minecraft:shulker_box"
}
}
}
/// A click outside a server's window (slot -999): predicted like
/// `MultiPlayerGameMode.handleContainerInput` (the carried stack after
/// `doClick`; the server drops the stack and swings the hand).
pub(super) fn remote_throw(&mut self, right: bool, input: horizon_client::menu::Input) {
use horizon_client::menu::Input;
let container = self
.remote
.as_ref()
.and_then(|r| r.menu.as_ref())
.map_or(0, |m| m.0);
if input == Input::Pickup
&& let Some(c) = self.carried.as_mut()
{
if right && c.count > 1 {
c.count -= 1;
} else {
self.carried = None;
}
self.swing_arm();
}
let carried = self.hashed_stack(&self.carried);
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
self.send(S::ContainerClick(play::ContainerClick {
container,
state,
slot: -999,
button: i8::from(right),
input: if input == Input::Throw { 4 } else { 0 },
changed: Vec::new(),
carried,
}));
}
/// `CreativeModeInventoryScreen.slotClicked` outside the window: the server knows nothing of
/// a stack picked from the creative screen, so the carried stack (an item tab's right click:
/// one of it) is dropped through `handleCreativeModeItemDrop`, a SetCreativeModeSlot to slot
/// -1, and the hand swings as `Player.drop` predicts.
pub(super) fn remote_creative_drop(&mut self, right: bool) {
let one = right && !self.creative_inventory_open();
let Some(carried) = self.carried.as_mut() else {
return;
};
let thrown = if one && carried.count > 1 {
carried.count -= 1;
ItemStack {
count: 1,
..carried.clone()
}
} else {
self.carried.take().expect("carried")
};
self.remote_creative_throw(thrown);
}
/// `MultiPlayerGameMode.handleCreativeModeItemDrop`: a stack thrown out of the creative
/// screen goes to the server as a SetCreativeModeSlot to slot -1, which drops it from the
/// player's hand; the client only swings (`Player.drop` on a client).
pub(super) fn remote_creative_throw(&mut self, thrown: ItemStack) {
if !self.creative {
return;
}
self.swing_arm();
let item = self.to_network(&Some(thrown));
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot: -1,
item,
}));
}
pub(super) fn remote_close_container(&mut self) {
let id = self
.remote
.as_ref()
.and_then(|r| r.menu.as_ref().map(|m| m.0))
.unwrap_or(0);
self.send(S::ContainerClose(play::ContainerClose { container: id }));
self.remote_close_local();
}
fn remote_close_local(&mut self) {
self.recipe_ui.reset_screen();
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
}
self.mount_menu_reset();
// The client's menu has no level access: what it holds is the server's.
self.station = None;
// AbstractContainerMenu.removed: the server gives the grid and the
// carried stack back to the inventory and syncs it; the copies here
// are stale the moment the window closes.
self.craft_grid.fill(None);
self.carried = None;
self.craft_size = 2;
if let Some(key) = self.container.take() {
// Server containers stay renderable but lose their copied slots.
if let Some(entity) = self.world.block_entities.get_mut(&key) {
for s in entity.slots_mut() {
*s = None;
}
}
}
}
/// A server's trading window (MerchantScreen over a ClientSideMerchant).
fn remote_open_merchant(
&mut self,
container: i32,
title: String,
text: horizon_client::text::Text,
) {
if let Some(r) = self.remote.as_mut() {
r.menu = Some((container, "minecraft:merchant".to_owned()));
r.menu_title = Some(text);
}
let session = horizon_client::merchant::Session::new(Vec::new(), true);
self.open_remote_merchant(session, (!title.is_empty()).then_some(title));
}
/// ClientboundMerchantOffersPacket.
fn remote_merchant_offers(&mut self, p: play::MerchantOffers) {
if self.open_menu_name(p.container) != "minecraft:merchant" {
return;
}
let cost = |g: &Self, c: &play::NetCost| {
g.to_stack(&Some(c.clone().into()))
.map(|stack| horizon_client::merchant::ItemCost { stack })
};
let offers: Vec<_> = p
.offers
.iter()
.filter_map(|o| {
let mut offer = horizon_client::merchant::MerchantOffer {
base_cost_a: cost(self, &o.cost_a)?,
cost_b: match &o.cost_b {
Some(b) => Some(cost(self, b)?),
None => None,
},
result: self.to_stack(&Some(o.result.clone()))?,
uses: o.uses,
max_uses: o.max_uses,
reward_exp: true,
special_price_diff: o.special_price,
demand: o.demand,
price_multiplier: o.price_multiplier,
xp: o.xp,
};
// setToOutOfStock.
if o.out_of_stock {
offer.uses = offer.max_uses;
}
Some(offer)
})
.collect();
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
m.session.offers = offers;
m.session.level = p.level;
m.session.xp = p.xp;
m.session.villager = p.show_progress;
}
}
/// ClientboundOpenScreenPacket for the menus Horizon draws.
fn remote_open_screen(&mut self, p: play::OpenScreen) {
// A new RecipeBookComponent owns fresh ghost slots and an overlay,
// even when the server replaces a menu without a close packet.
self.recipe_ui.reset_screen();
let menu = self
.remote
.as_ref()
.and_then(|r| {
r.builtin
.get("minecraft:menu")?
.name(p.menu)
.map(str::to_owned)
})
.unwrap_or_default();
let target = self
.remote
.as_mut()
.and_then(|r| r.use_target.take())
.map(|t| (t[0], t[1], t[2]));
if menu == "minecraft:lectern" {
// LecternScreen: the book and page come in the menu's content and data.
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, menu));
}
self.remote_open_lectern(target.unwrap_or_default());
return;
}
if let Some(kind) = Self::remote_station_kind(&menu) {
let title = menu_title(&p.title);
let pos = target.map_or([0, 0, 0], |t| [t.0, t.1, t.2]);
self.remote_open_station(p.container, menu, kind, title, pos);
return;
}
if menu == "minecraft:merchant" {
// A trading window: its offers follow in a MerchantOffers packet.
let title = plain(
&p.title,
&self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default(),
);
self.remote_open_merchant(p.container, title, menu_title(&p.title));
return;
}
let mod_type = horizon_client::containers::mod_menu_type(&menu);
let entity = match menu.as_str() {
"minecraft:crafting" => None,
"minecraft:furnace" | "minecraft:blast_furnace" | "minecraft:smoker" => {
let kind = match menu.as_str() {
"minecraft:blast_furnace" => FurnaceKind::BlastFurnace,
"minecraft:smoker" => FurnaceKind::Smoker,
_ => FurnaceKind::Furnace,
};
Some(BlockEntity::Furnace(Furnace {
kind,
items: [None, None, None],
lit_remaining: 0,
lit_total: 0,
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: std::collections::HashMap::new(),
}))
}
// HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers
// and droppers).
"minecraft:hopper" => Some(BlockEntity::Chest(vec![None; 5])),
// BrewingStandMenu: its times come in ContainerSetData.
"minecraft:brewing_stand" => Some(BlockEntity::Brewing(Box::new(
horizon_client::brewing::BrewingStand::new(),
))),
"minecraft:generic_3x3" => Some(BlockEntity::Chest(vec![None; 9])),
// CrafterMenu: the disabled slots and triggered come in ContainerSetData.
"minecraft:crafter_3x3" => Some(BlockEntity::Crafter(Default::default())),
m if m.starts_with("minecraft:generic_9x") || m == "minecraft:shulker_box" => {
let rows = m
.strip_prefix("minecraft:generic_9x")
.and_then(|r| r.parse::<usize>().ok())
.unwrap_or(3);
Some(BlockEntity::Chest(vec![None; rows * 9]))
}
// A content mod's menu type: its container's slots, which its
// mod's screen places.
_ if mod_type.is_some() => Some(BlockEntity::Mod(Box::new(
horizon_client::containers::ModBlockEntityFields {
items: vec![None; mod_type.map_or(0, |t| t.slots)],
..Default::default()
}
.into(),
))),
_ => {
// Unsupported menu: tell the server we closed it.
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
self.chat_line(
horizon_client::text::Text::literal(format!(
"(Horizon cannot show {menu} screens yet)"
)),
horizon_client::chat::ChatSource::Client,
);
return;
}
};
let title = menu_title(&p.title);
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, menu.clone()));
r.menu_title = Some(title);
}
self.craft_size = if menu == "minecraft:crafting" { 3 } else { 2 };
self.container = None;
if let Some(entity) = entity {
// The clicked block shows the menu when it holds that container;
// an ender chest's or a double chest's slots are no one block's,
// so they go under a key no block has.
let holds = |k: &(i32, i32, i32)| {
let name = &self.res.facts().get(self.world.block(k.0, k.1, k.2)).name;
BlockEntity::for_block(name).is_some_and(|e| {
e.id() == entity.id() && e.slots().len() == entity.slots().len()
})
};
let key = target.filter(holds).unwrap_or((i32::MIN, 0, 0));
self.world.block_entities.insert(key, entity);
self.container = Some(key);
self.open_native_screen(key, target, p.container, &menu);
// A mod menu type shows through its mod's screen only: without one
// (`MenuScreens.create` finds no screen) the window closes again.
if self.mod_screen.is_none() && mod_type.is_some() {
self.world.block_entities.remove(&key);
self.container = None;
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
}
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
return;
}
}
self.screen = Screen::Inventory;
self.capture(false);
}
/// `ClientPacketListener.handleMountScreenOpen`: a horse's or llama's
/// inventory; for another entity the window is closed again.
fn remote_open_mount(&mut self, p: play::MountScreenOpen) {
let columns = usize::try_from(p.columns).unwrap_or(0).min(5);
if !self.open_remote_mount_menu(p.entity as u32, columns) {
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
return;
}
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, format!("{MOUNT_MENU}{columns}")));
r.menu_title = None;
}
}
/// The type of the menu a server opened (`MenuType`'s id).
pub(super) fn remote_menu_type(&self) -> Option<&str> {
Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str())
}
/// The title the server gave the menu it opened.
pub(super) fn remote_menu_title(&self) -> Option<horizon_client::text::Text> {
self.remote.as_ref()?.menu_title.clone()
}
/// A language change reaches the server-sent components too: their titles
/// are resolved when the packet arrives, with the session's language.
pub(super) fn refresh_remote_language(&mut self) {
if let Some(lang) = horizon_client::lang::current()
&& let Some(r) = self.remote.as_mut()
{
r.lang = Arc::new(lang.map().clone());
}
}
/// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title.
pub(super) fn remote_chest_menu(&self) -> Option<(usize, horizon_client::text::Text)> {
let r = self.remote.as_ref()?;
let rows = r
.menu
.as_ref()?
.1
.strip_prefix("minecraft:generic_9x")?
.parse()
.ok()?;
Some((
rows,
r.menu_title
.clone()
.unwrap_or_else(horizon_client::text::Text::empty),
))
}
/// ServerboundPlayerCommandPacket STOP_SLEEPING.
pub(super) fn remote_stop_sleeping(&mut self) {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 0,
data: 0,
}));
}
pub(super) fn remote_respawn(&mut self) {
self.send(S::ClientCommand(play::ClientCommand { action: 0 }));
}
/// Connecting / loading screens; true when it covered the frame.
pub(super) fn remote_overlay(&mut self, batch: &mut HudBatch) -> bool {
let Some(r) = &self.remote else { return false };
let loading = r.level_load.is_active();
if self.player.is_some() && !loading {
return false;
}
let (w, h) = (batch.width, batch.height);
if let Some(since) = r.reconfiguring {
// ServerReconfigScreen narrates its title.
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new(
"reconfiguring",
super::screen_info::tr("connect.reconfiguring", &[]),
)
});
self.draw_reconfiguring(batch, since);
return true;
}
if r.joined || loading {
// LevelLoadingScreen: the world's terrain comes in. A server's
// chunk status view and progress are the integrated server's alone
// (`LevelLoadTracker.statusView`, `hasProgress`), and so is what
// the narrator says of it.
let progress = self
.integrated
.as_ref()
.map(|i| i.load_view())
.filter(|v| v.has_progress())
.map(|v| v.server_progress());
horizon_client::widgets::begin_screen(|| {
super::screen_info::level_loading(progress, &super::screen_info::tr)
});
self.draw_level_loading_background(batch, r.level_load.reason);
let atlas = &self.gui.atlas;
match self.integrated.as_ref().map(|i| i.load_view()) {
Some(view) => {
let status = |x: i32, z: i32| view.status(x, z);
let progress = view.has_progress().then_some(self.loading_progress);
horizon_client::level_loading::draw(batch, atlas, Some(&status), progress);
}
None => horizon_client::level_loading::draw(batch, atlas, None, None),
}
return true;
}
let cancel = self.connect_cancel((w, h)).is_some();
// ConnectScreen.extractRenderState: "Joining the world" every 2 s (a
// server's: single player's isn't joined through ConnectScreen).
if self.integrated.is_none()
&& let Some(r) = self.remote.as_mut()
&& r.joining_narrated
.is_none_or(|t| t.elapsed() > Duration::from_millis(2000))
{
r.joining_narrated = Some(Instant::now());
self.say_system_now(&horizon_client::text::Text::translate(
"narrator.joining",
&[],
));
}
let Some(r) = &self.remote else { return false };
// ConnectScreen: `GameNarrator.NO_TITLE`, then its Cancel; single
// player's server is joined under its LevelLoadingScreen.
let single_player = self.integrated.is_some();
horizon_client::widgets::begin_screen(|| {
if single_player {
super::screen_info::level_loading(None, &super::screen_info::tr)
} else {
horizon_client::widgets::ScreenInfo::new("connect", "")
}
});
let atlas = &self.gui.atlas;
let status = horizon_client::text::Text::literal(r.status.as_str());
horizon_client::level_loading::draw_connecting(batch, atlas, &status, self.cursor, cancel);
true
}
/// Connected or connecting with no player yet: `ConnectScreen`, then
/// `LevelLoadingScreen`, over the panorama with its blur.
pub(super) fn remote_loading(&self) -> bool {
use horizon_client::level_loading::Reason;
self.remote.as_ref().is_some_and(|r| {
if r.level_load.is_active() {
// The portal screens have no panorama, so no blur.
r.level_load.reason == Reason::Other
} else {
self.player.is_none()
}
})
}
/// `ServerReconfigScreen`: a vertical layout of cells padded by 10, the
/// title (9 high) over Disconnect (150 x 20), centred. The title's and the
/// button's top left corners.
fn reconfigure_layout(&self, (w, h): (f32, f32)) -> ((f32, f32), (f32, f32)) {
let title = horizon_client::text::Text::translate("connect.reconfiguring", &[]);
let title_w = self.gui.atlas.font.width_text(&title).ceil() as i32;
let width = (title_w + 20).max(170);
let left = ((w as i32 - width) as f32 * 0.5) as i32;
let top = ((h as i32 - 69) as f32 * 0.5) as i32;
let across = |child: i32| (10.0 + ((width - child - 10) as f32 - 10.0) * 0.5) as i32;
(
((left + across(title_w)) as f32, (top + 10) as f32),
((left + across(150)) as f32, (top + 39) as f32),
)
}
/// `ServerReconfigScreen` over the panorama; Disconnect wakes up after
/// `DISCONNECT_TIME` (600 ticks).
fn draw_reconfiguring(&self, batch: &mut HudBatch, since: u64) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
let title = horizon_client::text::Text::translate("connect.reconfiguring", &[]);
let ((tx, ty), (bx, by)) = self.reconfigure_layout((w, h));
batch.text_styled(atlas, &title, tx, ty, u32::MAX, true);
// CommonComponents.GUI_DISCONNECT
let label = horizon_client::text::Text::translate("menu.disconnect", &[]);
let active = self.ticks.saturating_sub(since) >= 600;
let hover = horizon_client::ui::hovered(self.cursor, bx, by, 150.0, 20.0);
horizon_client::ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, active, hover);
}
/// `ConnectScreen`'s Cancel: a server's (single player's server isn't
/// joined through `ConnectScreen`), until the login is done.
fn connect_cancel(&self, (w, h): (f32, f32)) -> Option<(f32, f32)> {
let r = self.remote.as_ref()?;
(self.player.is_none() && !r.joined && self.integrated.is_none())
.then(|| horizon_client::level_loading::connect_cancel(w, h))
}
/// A click on `ConnectScreen`: Cancel aborts the connection
/// (`connect.aborted`) and goes back. Escape does nothing there
/// (`shouldCloseOnEsc` is false).
pub(super) fn connect_screen_click(&mut self) -> Result<()> {
// ServerReconfigScreen's Disconnect, once it is active.
if let Some(since) = self.remote.as_ref().and_then(|r| r.reconfiguring) {
let (_, (x, y)) = self.reconfigure_layout(self.gui_size());
if self.ticks.saturating_sub(since) >= 600
&& horizon_client::ui::hovered(self.cursor, x, y, 150.0, 20.0)
{
self.audio.play_button_click();
self.leave_server(None)?;
}
return Ok(());
}
let Some((x, y)) = self.connect_cancel(self.gui_size()) else {
return Ok(());
};
if horizon_client::ui::hovered(self.cursor, x, y, 200.0, 20.0) {
self.audio.play_button_click();
self.leave_server(None)?;
}
Ok(())
}
/// The server's death message; chat uses the shared ChatComponent.
pub(super) fn remote_hud(&self, batch: &mut HudBatch) {
let Some(r) = &self.remote else { return };
let atlas = &self.gui.atlas;
let w = batch.width;
if self.screen == Screen::Dead
&& let Some(message) = &r.death_message
{
// DeathScreen draws the styled cause component.
let tw = atlas.font.width_text(message);
batch.text_styled(
atlas,
message,
((w - tw) / 2.0).floor(),
85.0,
u32::MAX,
true,
);
}
}
/// Shared flag 6 of an entity on a server's client (`None` without a server).
pub(super) fn remote_glowing(&self, id: u32) -> Option<bool> {
let r = self.remote.as_ref()?;
Some(r.glowing.contains(&(id as i32)))
}
/// The profile name of another player the server sent, by entity id.
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
let uuid = r.level_players.get(&(id as i32))?;
if *uuid == r.profile {
return None;
}
r.level_player_name(id as i32, uuid)
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (their
/// profiles', which outlive the player list's entries).
pub(super) fn remote_player_names(&self) -> Vec<(u32, String)> {
let Some(r) = self.remote.as_ref() else {
return Vec::new();
};
r.level_players
.iter()
.filter(|(_, uuid)| **uuid != r.profile)
.filter_map(|(id, uuid)| Some((*id as u32, r.level_player_name(*id, uuid)?.to_owned())))
.collect()
}
/// `ClientClockManager.tick`: the clock moves on by the game time that
/// passed since its last tick, at its rate. A game time the server sets
/// forward (its sync every second) moves the clock with it.
fn remote_clock_tick(&mut self) {
let Some(r) = self.remote.as_mut() else {
return;
};
let delta = self.world.game_time - r.clock_game_time;
r.clock_game_time = self.world.game_time;
let partial = f64::from(r.clock_partial) + delta as f64 * f64::from(r.clock_rate);
let full = partial.floor();
r.clock_partial = (partial - full) as f32;
self.world.day_time += full as i64;
}
pub(super) fn remote_status(&self) -> Option<serde_json::Value> {
let r = self.remote.as_ref()?;
Some(serde_json::json!({
"address": horizon_client::net::shown_address(&r.session.address),
"name": r.session.name,
"joined": r.joined,
// ServerboundPlayerLoadedPacket sent: the level around the
// player is ready, and the client ticks and sends its moves.
"loaded": r.loaded_sent,
"status": r.status,
"entity_id": r.entity_id,
"dimension": r.dimension.name,
"game_mode": r.game_mode,
"chunks": self.world.chunks.len(),
"entities": r.entities.values().fold(std::collections::BTreeMap::<&str, u32>::new(), |mut m, t| {
*m.entry(t.kind.as_str()).or_default() += 1;
m
}),
"players": r.players.values().collect::<Vec<_>>(),
// Where the other players' entities are (harnesses check movement).
// What the other players hold and wear, as this client draws them.
"player_equipment": self
.mobs
.iter()
.filter(|m| r.level_players.contains_key(&(m.id as i32)))
.map(|m| {
let e = &m.extra;
let armor = [&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref());
serde_json::json!({
"id": m.id,
"swinging": m.ai.swinging,
"using": m.extra.using_item,
"armor": armor,
"main_hand": e.main_hand.as_deref(),
"off_hand": e.off_hand.as_deref(),
})
})
.collect::<Vec<_>>(),
"player_positions": r
.entities
.values()
.filter(|t| t.kind == "minecraft:player")
.map(|t| t.position)
.collect::<Vec<_>>(),
// The other players this client draws dead (lying down, after EntityEvent DEATH).
"dead_players": self
.mobs
.iter()
.filter(|m| r.level_players.contains_key(&(m.id as i32)) && !m.alive())
.map(|m| m.id)
.collect::<Vec<_>>(),
// The other players this client draws lying in a bed (SLEEPING_POS).
"lying_players": self
.mobs
.iter()
.filter(|m| r.level_players.contains_key(&(m.id as i32)) && m.extra.ints[1] != 0)
.map(|m| m.id)
.collect::<Vec<_>>(),
// The player's own model exists (third person and the inventory draw it).
"player_model": self.player_model.is_some(),
// The world view draws its body now (third person, or lying in a bed;
// not while invisible).
"own_body": self.player_model_shown() && self.own_body_visible(),
"text_display_vertices": self.text_display_vertices,
"text_displays": self.text_displays.len(),
"health": r.health.0,
"food": r.health.1,
"decode_errors": r.decode_errors,
"chat": r.chat.iter().rev().take(10).rev().map(|(t, _)| t.clone()).collect::<Vec<_>>(),
}))
}
}
/// `Entity.lookAt`'s rotation from `from` towards `to`, as (yaw, pitch).
fn look_at_rotation(from: [f64; 3], to: [f64; 3]) -> (f32, f32) {
use horizon_core::geometry::trig::atan2;
let (dx, dy, dz) = (to[0] - from[0], to[1] - from[1], to[2] - from[2]);
let horizontal = (dx * dx + dz * dz).sqrt();
// `* 180.0F / (float)Math.PI` on the double.
let degrees = |r: f64| r * 180.0 / f64::from(std::f32::consts::PI);
let pitch = wrap_degrees((-degrees(atan2(dy, horizontal))) as f32);
let yaw = wrap_degrees(degrees(atan2(dz, dx)) as f32 - 90.0);
(yaw, pitch)
}
/// `PositionMoveRotation.calculateAbsolute`'s pitch: the packet's pitch,
/// added to `source` when it is relative, clamped to +-90.
fn position_packet_pitch(source: f32, change: f32, relative: bool) -> f32 {
let offset = if relative { source } else { 0.0 };
(offset + change).clamp(-90.0, 90.0)
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(a: f32) -> f32 {
let a = a % 360.0;
if a >= 180.0 {
a - 360.0
} else if a < -180.0 {
a + 360.0
} else {
a
}
}
/// `LivingEntity.tick`'s body target and `tickHeadTurn` for another player:
/// the body turns toward where it walks (backwards when walking back), or to
/// the look while the arm swings, and stays within 50 degrees of the look.
fn remote_player_head_turn(m: &mut Mob) {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let mut target = m.body_yaw;
if (dx * dx + dz * dz) as f32 > 0.002_500_000_2 {
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
let facing = (wrap_degrees(m.yaw) - walk).abs();
target = if 95.0 < facing && facing < 265.0 {
walk - 180.0
} else {
walk
};
}
if m.anim.attack_anim > 0.0 {
target = m.yaw;
}
m.body_yaw += wrap_degrees(target - m.body_yaw) * 0.3;
let head = wrap_degrees(m.yaw - m.body_yaw);
if head.abs() > 50.0 {
m.body_yaw += head - head.signum() * 50.0;
}
}
/// A click sent to a server: its slot, button and input, the slots it
/// changed and the carried stack after it.
type Predicted = (
i16,
i32,
Input,
Vec<(i16, Option<ItemStack>)>,
Option<ItemStack>,
);
/// How many stacks the creative screen keeps in step with the server (`Remote::synced`).
const CREATIVE_SYNCED: usize = 41;
/// The player's stacks in `Remote::synced` order: the 36 inventory slots, the off hand, then
/// the armour from the head down. `CreativeInventoryListener.slotChanged` sends every
/// InventoryMenu slot the creative screen changes, the armour slots too.
fn creative_sync_stacks(
inventory: &[Option<ItemStack>],
offhand: &Option<ItemStack>,
armor: &[Option<ItemStack>; 4],
) -> Vec<Option<ItemStack>> {
let mut stacks: Vec<Option<ItemStack>> = inventory.iter().take(36).cloned().collect();
stacks.resize(36, None);
stacks.push(offhand.clone());
stacks.extend(armor.iter().cloned());
stacks
}
/// Entries of `creative_sync_stacks` that went from `before` to `now` are synced as `now`.
fn mark_synced(
synced: &mut [Option<ItemStack>],
before: &[Option<ItemStack>],
now: Vec<Option<ItemStack>>,
) {
for (i, stack) in now.into_iter().enumerate() {
if before.get(i) != Some(&stack)
&& let Some(s) = synced.get_mut(i)
{
*s = stack;
}
}
}
/// InventoryMenu's slot for entry `i` of `creative_sync_stacks`: the hotbar is 36..=44, the
/// main inventory 9..=35, the off hand 45 and the armour 5..=8 (head first).
fn creative_sync_slot(i: usize) -> i16 {
(match i {
0..9 => 36 + i,
36 => 45,
37.. => 5 + (i - 37),
_ => i,
}) as i16
}
/// `MultiPlayerGameMode.handleContainerInput` for each click on the engine's
/// copy of the menu: the click with the slots it changed and the carried
/// stack after it, and whether a click dropped a stack. Every drop of
/// `AbstractContainerMenu.doClick` (a THROW, a click outside the window, a
/// swap or a result move with no room left) is `Prediction.PREDICTED`.
/// None when the engine refuses a click.
fn predict_clicks(
m: &mut horizon_client::menu::Menu,
env: &horizon_client::menu::Env,
clicks: &[(i16, i32, Input)],
) -> Option<(Vec<Predicted>, bool)> {
let mut predicted = Vec::with_capacity(clicks.len());
let mut dropped = false;
for &(slot, button, input) in clicks {
let before = menu_items(m);
let thrown = m.dropped.len();
m.click(env, i32::from(slot), button, input).ok()?;
dropped |= m.dropped.len() > thrown;
let changed = changed_slots(&before, m);
predicted.push((slot, button, input, changed, m.carried.clone()));
}
Some((predicted, dropped))
}
fn menu_items(m: &horizon_client::menu::Menu) -> Vec<Option<ItemStack>> {
(0..m.slot_count()).map(|i| m.slot(i).cloned()).collect()
}
/// The slots whose stack is no longer `before`'s (`ItemStack.matches`).
fn changed_slots(
before: &[Option<ItemStack>],
m: &horizon_client::menu::Menu,
) -> Vec<(i16, Option<ItemStack>)> {
(0..m.slot_count())
.filter(|&i| m.slot(i) != before.get(i).and_then(Option::as_ref))
.map(|i| (i as i16, m.slot(i).cloned()))
.collect()
}
/// ServerboundContainerClickPacket's `ContainerInput` ordinal.
pub(super) fn input_ordinal(input: Input) -> i32 {
match input {
Input::Pickup => 0,
Input::QuickMove => 1,
Input::Swap => 2,
Input::Clone => 3,
Input::Throw => 4,
Input::QuickCraft => 5,
Input::PickupAll => 6,
}
}
/// The client's world for a guess at what a click does: reads go to the
/// world, writes stay here and are dropped.
struct DryLevel<'w> {
base: &'w dyn horizon_world::level::LevelAccess,
changed: HashMap<horizon_core::position::BlockPos, BlockStateId>,
random: horizon_core::random::LegacyRandom,
/// The sounds the use plays (a door's creak).
sounds: Vec<DrySound>,
}
type DrySound = (
horizon_core::position::BlockPos,
std::sync::Arc<str>,
f32,
f32,
);
/// The blocks a use on the dry level set.
type DryChanges = Vec<(horizon_core::position::BlockPos, BlockStateId)>;
/// What a use on the dry level changed, and the sounds it played.
type DryUse = (DryChanges, Vec<DrySound>);
/// Where `BlockItem.place` on the dry level placed, and the blocks it set.
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
/// copper door, trapdoor or fence gate, a lever, a button or a cauldron, whose fill level
/// changes as the bucket, bottle or dye lands. An iron door or trapdoor has no hand use.
pub(super) fn hand_operated(name: &str) -> bool {
let name = name.trim_start_matches("minecraft:");
((name.ends_with("_door") || name.ends_with("_trapdoor")) && !name.starts_with("iron_"))
|| name.ends_with("_fence_gate")
|| name.ends_with("_button")
|| name == "lever"
|| name.ends_with("cauldron")
}
impl<'w> DryLevel<'w> {
fn new(base: &'w dyn horizon_world::level::LevelAccess) -> Self {
Self {
base,
changed: HashMap::new(),
random: horizon_core::random::LegacyRandom::new(0),
sounds: Vec::new(),
}
}
}
impl horizon_world::level::LevelAccess for DryLevel<'_> {
fn emit(&mut self, event: horizon_world::level::LevelEvent) {
if let horizon_world::level::LevelEvent::Sound {
pos,
sound,
volume,
pitch,
} = event
{
self.sounds.push((pos, sound, volume, pitch));
}
}
fn block_state(&self, pos: horizon_core::position::BlockPos) -> BlockStateId {
self.changed
.get(&pos)
.copied()
.unwrap_or_else(|| self.base.block_state(pos))
}
fn set_raw(
&mut self,
pos: horizon_core::position::BlockPos,
state: BlockStateId,
) -> Option<BlockStateId> {
let previous = self.block_state(pos);
self.changed.insert(pos, state);
Some(previous)
}
fn is_loaded(&self, pos: horizon_core::position::BlockPos) -> bool {
self.base.is_loaded(pos)
}
fn min_y(&self) -> i32 {
self.base.min_y()
}
fn max_y(&self) -> i32 {
self.base.max_y()
}
fn block_light(&self, pos: horizon_core::position::BlockPos) -> i32 {
self.base.block_light(pos)
}
fn sky_light(&self, pos: horizon_core::position::BlockPos) -> i32 {
self.base.sky_light(pos)
}
fn sky_darken(&self) -> i32 {
self.base.sky_darken()
}
fn is_unobstructed(&self, pos: horizon_core::position::BlockPos, state: BlockStateId) -> bool {
self.base.is_unobstructed(pos, state)
}
fn random(&mut self) -> &mut horizon_core::random::LegacyRandom {
&mut self.random
}
/// A mod's bone meal answer is read, never grown here: the preview asks
/// the level it stands in for, as `isValidBonemealTarget` is read on both
/// sides.
fn is_valid_bonemeal_target(
&self,
pos: horizon_core::position::BlockPos,
state: BlockStateId,
) -> Option<bool> {
self.base.is_valid_bonemeal_target(pos, state)
}
fn game_time(&self) -> i64 {
self.base.game_time()
}
}
/// `InteractionHand` on the wire.
fn hand_id(hand: Hand) -> i32 {
match hand {
Hand::Main => 0,
Hand::Off => 1,
}
}
/// Whether `Item.use` of `info` ends `Minecraft.startUseItem`'s click, so the
/// off hand gets no turn. `FireworkRocketItem.use` answers SUCCESS while the
/// player glides (the rocket boosts them) and PASS otherwise.
fn use_ends_click(info: &horizon_client::items::ItemInfo, fall_flying: bool) -> bool {
item_has_use(info) || (fall_flying && &*info.id == "minecraft:firework_rocket")
}
/// Whether `Item.use` of this item answers SUCCESS on this client, which
/// swings (`MultiPlayerGameMode.useItem`'s `result.shouldSwing()`), rather
/// than CONSUME, which only ends the click: a bow, crossbow, trident or
/// spear starting its charge, `ItemUtils.startUsingInstantly`'s food, drink,
/// spyglass, shield and goat horn, and the ender eye's SUCCESS_SERVER (the
/// server's swing source, not this client's).
fn remote_use_swings(held: &ItemStack, consumable: bool) -> bool {
let id = &*held.item;
!matches!(
id,
"minecraft:bow"
| "minecraft:crossbow"
| "minecraft:trident"
| "minecraft:spyglass"
| "minecraft:goat_horn"
| "minecraft:ender_eye"
| "minecraft:brush"
) && !consumable
&& horizon_client::spear::Kinetic::of(id).is_none()
}
/// Whether `Item.use` usually acts on this item (eats, drinks, equips, raises,
/// throws, shoots, fills or places), which ends `Minecraft.startUseItem` at
/// its hand. Tools and materials pass, and the off hand gets its turn. The
/// server decides; this is the client's guess of whether to go on. A carrot or warped fungus
/// on a stick is not here: `FoodOnAStickItem.use` passes on a client, and only the server's
/// answer to a boost that can run swings the hand.
fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
let id = info.id.trim_start_matches("minecraft:");
id.ends_with("_spear")
|| info.consumable.is_some()
|| info.equip_slot.is_some()
|| info.blocks_attacks.is_some()
|| horizon_client::items::bucket_contents(&info.id).is_some()
|| id.ends_with("_boat")
|| id.ends_with("_raft")
|| id.ends_with("_bundle")
|| matches!(
id,
"bow"
| "crossbow"
| "trident"
| "spyglass"
| "fishing_rod"
| "snowball"
| "egg"
| "blue_egg"
| "brown_egg"
| "ender_pearl"
| "ender_eye"
| "wind_charge"
| "splash_potion"
| "lingering_potion"
| "experience_bottle"
| "bucket"
| "glass_bottle"
| "map"
| "writable_book"
| "written_book"
| "goat_horn"
| "bundle"
| "brush"
)
}
/// The view distance a client asks for: its render distance, which Horizon
/// lets go past vanilla's 32 (a vanilla server still caps it at its own).
fn client_view_distance(render_distance: i32) -> i8 {
render_distance.clamp(2, i32::from(i8::MAX)) as i8
}
/// `ClientPacketListener.handleBlockEntityData` loads the tag into the existing
/// block entity, so the state the client builds itself stays: copied from `old`
/// into `new`, the block entity decoded from the tag.
fn keep_client_state(old: Option<&BlockEntity>, new: &mut BlockEntity) {
match (old, new) {
// The turn of a vault's item and a trial spawner's mob (VaultClientData).
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
// A beacon's beam (`BeaconBlockEntity.loadAdditional` reads only the
// powers): a fresh one draws nothing until the next tick walks it again.
(Some(BlockEntity::Beacon(old)), BlockEntity::Beacon(new)) => {
new.sections.clone_from(&old.sections);
new.checking.clone_from(&old.checking);
new.last_check_y = old.last_check_y;
}
_ => {}
}
}
/// `LocalPlayer.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS)`: the server's
/// flags (1 using, 2 the off hand) start a use the client is not running
/// (`startUsingItem`, which needs an item in that hand), and stop one it is
/// (`stopUsingItem`). `using` is the slot in use and its ticks.
fn synced_local_use(
using: Option<(usize, u32)>,
flags: i8,
selected: usize,
has_item: impl Fn(usize) -> bool,
) -> Option<(usize, u32)> {
let server_using = flags & 1 != 0;
match using {
None if server_using => {
let slot = if flags & 2 != 0 {
super::shield::OFFHAND_SLOT
} else {
selected
};
has_item(slot).then_some((slot, 0))
}
Some(_) if !server_using => None,
using => using,
}
}
struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>);
struct RemoteUseWorld<'g, 'a>(&'g mut Game<'a>);
impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_> {
fn use_entity(&mut self) -> bool {
self.0.remote_use_entity(Hand::Main)
}
fn use_block(&mut self) -> bool {
self.0.remote_use_block(Hand::Main)
}
fn use_item(&mut self) {
self.0.remote_use_hands();
}
}
impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_, '_> {
fn pick_hit_result(&mut self) {
self.0.tick_hit = Some(self.0.pick());
}
fn release_hit_result(&mut self) {
self.0.tick_hit = None;
}
fn using_item(&self) -> bool {
self.0.using.is_some()
}
fn release_using(&mut self) {
if let Some((slot, ticks)) = self.0.using
&& self.0.mod_use_tick(slot, ticks, true)
{
return;
}
self.0.release_remote_use();
}
fn start_attack(&mut self) -> bool {
self.0.remote_start_attack()
}
fn start_use(&mut self) {
self.0.remote_use();
}
fn pick(&mut self) {
// Minecraft.pickBlockOrEntity: an entity before the block is asked
// for (`handlePickItemFromEntity`), else the block.
if let Some(entity) = self.0.pick_entity_hit() {
if let Some(id) = entity {
if self.0.native_interaction(2, Hand::Main).is_some() {
return;
}
self.0.send(S::PickItemFromEntity(play::PickItemFromEntity {
id,
include_data: self.0.has_control_down(),
}));
}
} else if let Some(hit) = self.0.pick() {
if self.0.native_interaction(2, Hand::Main).is_some() {
return;
}
self.0.send(S::PickItemFromBlock(play::PickItemFromBlock {
pos: hit.pos,
include_data: self.0.has_control_down(),
}));
}
}
fn use_delay(&self) -> u32 {
self.0.use_delay
}
fn continue_attack(&mut self, down: bool) {
self.0.remote_continue_attack(down);
}
}
impl Remote {
/// `PlayerInfo.getMessageValidator().updateAndValidate(message)` for `message.sender`.
fn validate_chat(
&mut self,
message: &secure_chat::Incoming,
now: i64,
) -> Option<secure_chat::Validated> {
let sender = message.sender;
if let Some(validator) = self.chat_validators.get_mut(&sender) {
return validator.update_and_validate(message, now);
}
let (mut validator, settled) = self.new_chat_validator(sender);
let validated = validator.update_and_validate(message, now);
// While the services' keys are still on their way the validator is a stopgap, not kept.
if settled {
self.chat_validators.insert(sender, validator);
}
validated
}
/// `ClientPacketListener.initializeChatSession` for `sender`'s entry: a session the services'
/// keys vouch for makes a keyed validator, anything else the fallback of
/// `enforcesSecureChat()` (`canValidateProfileKeys() && serverEnforcesSecureChat`). `false`
/// while the keys are not known yet.
fn new_chat_validator(&self, sender: u128) -> (secure_chat::MessageValidator, bool) {
let keys = self.services_keys.lock().unwrap_or_else(|e| e.into_inner());
let (can_validate, settled) = match &*keys {
ServicesKeys::Ready(_) => (true, true),
ServicesKeys::Missing => (false, true),
ServicesKeys::Pending => (false, false),
};
let fallback =
secure_chat::MessageValidator::fallback(can_validate && self.enforces_secure_chat);
let (ServicesKeys::Ready(keys), Some(Some((id, expires_at, key, signature)))) =
(&*keys, self.chat_sessions.get(&sender))
else {
if matches!(&*keys, ServicesKeys::Missing)
&& self.chat_sessions.get(&sender).is_some_and(Option::is_some)
{
log::warn!(
"Ignoring chat session from {sender:032x} due to missing Services public key"
);
}
return (fallback, settled);
};
let profile_key = secure_chat::ProfileKey {
expires_at: *expires_at,
key: key.clone(),
signature: signature.clone(),
};
match secure_chat::ChatSession::validate(*id, profile_key, keys, sender) {
Ok(session) => (secure_chat::MessageValidator::keyed(session), true),
Err(why) => {
log::error!("Failed to validate profile key for player {sender:032x}: {why}");
(fallback, true)
}
}
}
/// A player entity's profile name: the one it was created with, else its
/// player-list entry's (an entity the list named after it spawned).
fn level_player_name(&self, id: i32, uuid: &u128) -> Option<&str> {
self.level_player_names
.get(&id)
.or_else(|| self.players.get(uuid))
.map(String::as_str)
}
/// Players in the server's player list, this one included.
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
/// The local player's profile UUID.
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
.iter()
.find(|(_, u)| **u == uuid)
.map(|(id, _)| *id as u32)
}
/// The kind and last sent position of a tracked server entity.
pub(super) fn tracked(&self, id: i32) -> Option<(&str, [f64; 3])> {
self.entities.get(&id).map(|t| (&*t.kind, t.position))
}
}
/// The textures to resolve skins from: every list entry's, and for a player
/// entity whose entry is gone the profile it kept. `kept` first takes, for each
/// player entity without one, its entry's textures (`AbstractClientPlayer.getPlayerInfo`
/// looks the PlayerInfo up once and holds it, removed from the list or not).
fn skin_entries(
level_players: &HashMap<i32, u128>,
listed: &HashMap<u128, horizon_client::skins::Textures>,
kept: &mut HashMap<i32, horizon_client::skins::Textures>,
) -> Vec<(u128, horizon_client::skins::Textures)> {
for (id, uuid) in level_players {
if !kept.contains_key(id)
&& let Some(textures) = listed.get(uuid)
{
kept.insert(*id, textures.clone());
}
}
let mut entries: Vec<_> = listed.iter().map(|(u, t)| (*u, t.clone())).collect();
for (id, textures) in kept.iter() {
if let Some(uuid) = level_players.get(id)
&& !listed.contains_key(uuid)
&& !entries.iter().any(|(u, _)| u == uuid)
{
entries.push((*uuid, textures.clone()));
}
}
entries
}
/// `AttributeInstance.calculateValue` before the attribute's range: the base,
/// `ADD_VALUE`s, then `ADD_MULTIPLIED_BASE`s of that, then each
/// `ADD_MULTIPLIED_TOTAL`.
fn snapshot_value(s: &play::AttributeSnapshot) -> f64 {
let amounts = |op: i32| {
s.modifiers
.iter()
.filter(move |m| m.operation == op)
.map(|m| m.amount)
};
let base = s.base + amounts(0).sum::<f64>();
let mut value = base + amounts(1).map(|a| base * a).sum::<f64>();
for amount in amounts(2) {
value *= 1.0 + amount;
}
value
}
/// `Util.getMillis` for the level load tracker's timeouts.
fn client_millis() -> u64 {
static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
ORIGIN
.get_or_init(std::time::Instant::now)
.elapsed()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::{keep_client_state, remote_use_swings, synced_local_use};
use horizon_client::{
containers::{Beacon, BlockEntity},
items::ItemStack,
};
use horizon_world::nbt::Tag;
/// `MultiPlayerGameMode.useItem`'s `shouldSwing`: the SUCCESS answers
/// swing, CONSUME's do not (#6425).
#[test]
fn a_thrown_item_swinging_on_a_server_and_a_charge_not() {
let stack = |id: &str| ItemStack::new(id, 1);
// SUCCESS: the throwables, a bucket, a boat, a bundle, a book, a map.
for id in [
"minecraft:ender_pearl",
"minecraft:splash_potion",
"minecraft:wind_charge",
"minecraft:snowball",
"minecraft:experience_bottle",
"minecraft:bucket",
"minecraft:oak_boat",
"minecraft:bundle",
"minecraft:writable_book",
"minecraft:map",
] {
assert!(remote_use_swings(&stack(id), false), "{id} swings");
}
// CONSUME: the charges and startUsingInstantly's uses, a drinkable
// potion among them (`consumable` set).
for id in [
"minecraft:bow",
"minecraft:crossbow",
"minecraft:trident",
"minecraft:spyglass",
"minecraft:goat_horn",
"minecraft:ender_eye",
] {
assert!(!remote_use_swings(&stack(id), false), "{id} does not swing");
}
// The kinetic weapons charge like a trident.
assert!(!remote_use_swings(&stack("minecraft:iron_spear"), false));
// A consumable (food, a drink) starts its use without a swing.
assert!(!remote_use_swings(&stack("minecraft:apple"), true));
}
#[test]
fn a_beacon_update_tag_keeps_the_beam_the_client_walked() {
let old = BlockEntity::Beacon(Box::new(Beacon {
levels: 1,
primary: Some("minecraft:speed".into()),
sections: vec![(0xF9FFFE, 2)],
checking: vec![(0xF9FFFE, 1)],
last_check_y: 70,
..Default::default()
}));
// BeaconBlockEntity.getUpdateTag: saveCustomOnly.
let tag = Tag::compound([
("id", Tag::String("minecraft:beacon".into())),
("x", Tag::Int(0)),
("y", Tag::Int(64)),
("z", Tag::Int(0)),
("primary_effect", Tag::String("minecraft:haste".into())),
(
"secondary_effect",
Tag::String("minecraft:regeneration".into()),
),
("Levels", Tag::Int(4)),
]);
let (_, mut new) = horizon_client::save::decode_block_entity(&tag).unwrap();
keep_client_state(Some(&old), &mut new);
let BlockEntity::Beacon(b) = new else {
panic!("not a beacon");
};
assert_eq!(b.sections, vec![(0xF9FFFE, 2)]);
assert_eq!(b.checking, vec![(0xF9FFFE, 1)]);
assert_eq!(b.last_check_y, 70);
// The powers and the levels are the tag's.
assert_eq!(b.primary.as_deref(), Some("minecraft:haste"));
assert_eq!(b.secondary.as_deref(), Some("minecraft:regeneration"));
assert_eq!(b.levels, 4);
}
#[test]
fn the_server_stopping_the_use_lowers_the_shield() {
// Player.disableShield's stopUsingItem: flags 0 or 2 while the
// off-hand shield is raised (LocalPlayer.onSyncedDataUpdated →
// stopUsingItem). stopUsingItem clears bit 0 and leaves bit 1, so a
// main-hand stop is 0 and an off-hand stop is 2.
let off = super::super::shield::OFFHAND_SLOT;
assert_eq!(synced_local_use(Some((off, 12)), 0, 3, |_| true), None);
assert_eq!(synced_local_use(Some((3, 5)), 0, 3, |_| true), None);
assert_eq!(synced_local_use(Some((off, 12)), 2, 3, |_| true), None);
}
#[test]
fn the_server_starting_a_use_starts_it_in_that_hand() {
let off = super::super::shield::OFFHAND_SLOT;
assert_eq!(synced_local_use(None, 1, 4, |_| true), Some((4, 0)));
assert_eq!(synced_local_use(None, 3, 4, |_| true), Some((off, 0)));
// LivingEntity.startUsingItem needs an item in the hand.
assert_eq!(synced_local_use(None, 1, 4, |_| false), None);
}
/// tooBigToInterpolate: a position sync measures from where the entity
/// is, a teleport from where its interpolation is headed.
#[test]
fn a_far_move_snaps_by_the_packets_own_measure() {
use super::{MovePacket, Path, PosRot, SteppedInterpolation};
let at = |x: f64| PosRot {
position: [x, 0.0, 0.0],
yaw: 0.0,
pitch: 0.0,
};
let mut handler = SteppedInterpolation::new(3);
// At x 0, headed for x 50.
handler.interpolate_to(at(0.0), Some(Path::Linear([50.0, 0.0, 0.0])), None);
let to = [100.0, 0.0, 0.0];
assert!(MovePacket::PositionSync.snaps(&handler, at(0.0), to));
assert!(!MovePacket::Teleport.snaps(&handler, at(0.0), to));
assert!(!MovePacket::Move.snaps(&handler, at(0.0), to));
// With nothing in flight, both measure from the entity.
let idle = SteppedInterpolation::new(3);
assert!(MovePacket::Teleport.snaps(&idle, at(0.0), to));
assert!(!MovePacket::Teleport.snaps(&idle, at(0.0), [60.0, 0.0, 0.0]));
}
/// A far sync puts the entity there and drops the steps it was walking;
/// a rotation alone keeps the position it is headed for and turns there
/// over the update interval.
#[test]
fn a_living_entitys_move_snaps_or_steps() {
use super::{MovePacket, Path, PosRot, SteppedInterpolation, stepped_move};
let at = |x: f64, yaw: f32| PosRot {
position: [x, 0.0, 0.0],
yaw,
pitch: 0.0,
};
let mut handler = SteppedInterpolation::new(3);
// Walking to x 5 when the far sync comes.
handler.interpolate_to(at(0.0, 0.0), Some(Path::Linear([5.0, 0.0, 0.0])), None);
assert!(handler.active());
let far = [100.0, 0.0, 0.0];
let snap = stepped_move(
&mut handler,
MovePacket::PositionSync,
at(0.0, 0.0),
far,
Some(Path::Linear(far)),
Some((90.0, 0.0)),
);
assert_eq!(snap, Some(at(100.0, 90.0)));
assert!(!handler.active());
assert_eq!(handler.interpolate(), None, "no step back toward x 5");
// A rotation-only move packet.
let turn = stepped_move(
&mut handler,
MovePacket::Move,
at(100.0, 90.0),
far,
None,
Some((180.0, 0.0)),
);
assert_eq!(turn, None);
let steps: Vec<_> = std::iter::from_fn(|| handler.interpolate()).collect();
assert_eq!(
steps.iter().map(|s| s.yaw).collect::<Vec<_>>(),
[120.0, 150.0, 180.0]
);
assert!(steps.iter().all(|s| s.position == far));
}
#[test]
fn matching_flags_leave_a_running_use_alone() {
assert_eq!(synced_local_use(Some((2, 7)), 1, 2, |_| true), Some((2, 7)));
assert_eq!(synced_local_use(None, 0, 2, |_| true), None);
}
/// `ChatType.Bound.decorate` and `decorateNarration`: a private message
/// shows as "Steve whispers to you: hi" and is narrated "Steve says hi";
/// a chat type the registry lacks is `minecraft:chat`'s.
#[test]
fn chat_is_narrated_with_its_chat_types_narration_decoration() {
use super::{Decoration, chat_type_decorate, play};
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
use serde_json::json;
let bound = play::ChatTypeBound {
chat_type: 0,
name: Tag::String(JavaString::from("Steve")),
target: None,
};
let content = json!("hi");
let (incoming, _) = parse_snbt(
r#"{chat:{translation_key:"commands.message.display.incoming",parameters:["sender","content"],style:{color:"gray",italic:1b}},narration:{translation_key:"chat.type.text.narrate",parameters:["sender","content"]}}"#,
)
.unwrap();
let chat = chat_type_decorate(Some(&incoming), Decoration::Chat, &bound, &content);
assert_eq!(chat["translate"], "commands.message.display.incoming");
assert_eq!(chat["color"], "gray");
assert_eq!(chat["with"], json!(["Steve", "hi"]));
assert_eq!(
chat_type_decorate(Some(&incoming), Decoration::Narration, &bound, &content),
json!({"translate": "chat.type.text.narrate", "with": ["Steve", "hi"]})
);
assert_eq!(
chat_type_decorate(None, Decoration::Narration, &bound, &content),
json!({"translate": "chat.type.text.narrate", "with": ["Steve", "hi"]})
);
assert_eq!(
chat_type_decorate(None, Decoration::Chat, &bound, &content)["translate"],
"chat.type.text"
);
}
/// A cauldron's fill level is a use this client predicts, and a guest's
/// host forwards its sound: every cauldron state counts, not only the
/// doors and levers.
#[test]
fn every_cauldron_counts_as_hand_operated() {
for name in [
"minecraft:cauldron",
"minecraft:water_cauldron",
"minecraft:lava_cauldron",
"minecraft:powder_snow_cauldron",
] {
assert!(super::hand_operated(name), "{name}");
}
assert!(!super::hand_operated("minecraft:water"));
}
/// An NPC plugin lists the NPC, spawns it, and takes the entry off the list:
/// the entity keeps the skin it found; a player spawned before its entry
/// arrives takes the entry once it comes.
#[test]
fn a_player_entity_keeps_the_skin_its_entry_gave() {
use horizon_client::skins::Textures;
use std::collections::HashMap;
let npc = Textures {
skin: Some("http://textures.minecraft.net/texture/npc".into()),
signed: true,
..Textures::default()
};
let (mut listed, mut kept) = (HashMap::from([(7u128, npc.clone())]), HashMap::new());
let mut players = HashMap::from([(2i32, 7u128), (3, 8)]);
assert_eq!(
super::skin_entries(&players, &listed, &mut kept),
[(7, npc.clone())]
);
listed.remove(&7);
assert_eq!(
super::skin_entries(&players, &listed, &mut kept),
[(7, npc.clone())]
);
// Player 8's entry comes after its entity.
let late = Textures {
skin: Some("http://textures.minecraft.net/texture/late".into()),
..npc.clone()
};
listed.insert(8, late.clone());
let mut entries = super::skin_entries(&players, &listed, &mut kept);
entries.sort_by_key(|(u, _)| *u);
assert_eq!(entries, [(7, npc), (8, late)]);
// A removed entity forgets its profile with it.
players.remove(&2);
kept.remove(&2);
listed.clear();
assert_eq!(super::skin_entries(&players, &listed, &mut kept).len(), 1);
}
/// An NPC plugin's scale: a base the plugin sets, or a modifier on 1.
#[test]
fn attribute_values_add_then_multiply_as_vanilla() {
use horizon_protocol::packets::play::{AttributeModifier, AttributeSnapshot};
let modifier = |amount, operation| AttributeModifier {
id: "minecraft:proof".into(),
amount,
operation,
};
let snapshot = |base, modifiers| AttributeSnapshot {
attribute: 0,
base,
modifiers,
};
assert_eq!(super::snapshot_value(&snapshot(2.0, vec![])), 2.0);
assert_eq!(
super::snapshot_value(&snapshot(1.0, vec![modifier(1.0, 0)])),
2.0
);
// (1 + 1) * (1 + 0.5) from the base, then * (1 + 1) of the total.
let all = vec![modifier(1.0, 0), modifier(0.5, 1), modifier(1.0, 2)];
assert_eq!(super::snapshot_value(&snapshot(1.0, all)), 6.0);
}
use super::Game;
/// `Entity.lookAt`: yaw 0 faces +Z, -90 faces +X; pitch -90 looks up.
#[test]
fn look_at_turns_towards_the_target() {
let close = |(yaw, pitch): (f32, f32), want: (f32, f32)| {
assert!(
(yaw - want.0).abs() < 1e-3 && (pitch - want.1).abs() < 1e-3,
"{yaw} {pitch}, want {want:?}"
);
};
let at = [10.0, 64.0, -5.0];
let off = |d: [f64; 3]| [at[0] + d[0], at[1] + d[1], at[2] + d[2]];
close(
super::look_at_rotation(at, off([0.0, 0.0, 3.0])),
(0.0, 0.0),
);
close(
super::look_at_rotation(at, off([3.0, 0.0, 0.0])),
(-90.0, 0.0),
);
close(
super::look_at_rotation(at, off([-3.0, 0.0, 0.0])),
(90.0, 0.0),
);
close(
super::look_at_rotation(at, off([0.0, 0.0, -3.0])),
(-180.0, 0.0),
);
close(
super::look_at_rotation(at, off([0.0, 2.0, 2.0])),
(0.0, -45.0),
);
close(
super::look_at_rotation(at, off([0.0, -4.0, 4.0])),
(0.0, 45.0),
);
}
/// `handleMovePlayer` moves the player's pitch and the model's old pitch
/// (`xRotO`) alike: a relative change keeps the ease a tick began, each
/// end clamped on its own, and an absolute one snaps both (#3844).
#[test]
fn a_position_packet_moves_both_pitch_ends() {
use super::position_packet_pitch as pitch;
// The tick is laying the head down from 30 to 0; the packet turns 10.
assert_eq!(
(pitch(30.0, 10.0, true), pitch(0.0, 10.0, true)),
(40.0, 10.0)
);
// Each end clamps on its own.
assert_eq!(
(pitch(80.0, 20.0, true), pitch(60.0, 20.0, true)),
(90.0, 80.0)
);
// An absolute pitch ignores both ends.
assert_eq!(
(pitch(30.0, -45.0, false), pitch(0.0, -45.0, false)),
(-45.0, -45.0)
);
assert_eq!(pitch(0.0, 120.0, false), 90.0);
}
/// The hurt sound of `ClientboundDamageEventPacket` follows its damage
/// type (`Player.getHurtSound`: the type's effects), with the codec's
/// `hurt` default for an unknown type (#4149).
#[test]
fn the_hurt_sound_follows_the_damage_events_type() {
use super::hurt_sound_for;
use horizon_protocol::registry::Dynamic;
use horizon_world::damage_source::Catalog;
let types = [
("minecraft:in_fire", "burning"),
("minecraft:drown", "drowning"),
("minecraft:sweet_berry_bush", "poking"),
("minecraft:freeze", "freezing"),
("minecraft:arrow", "hurt"),
];
// The types the server synchronized, in registry order.
let mut registries = Dynamic::default();
registries.add(horizon_protocol::packets::configuration::RegistryData {
registry: "minecraft:damage_type".into(),
entries: types
.iter()
.map(
|(id, _)| horizon_protocol::packets::configuration::RegistryEntry {
id: (*id).into(),
data: None,
},
)
.collect(),
});
// The damage catalog: one entry per type above, each with its effects.
let bits = |f: f32| format!("{:08x}", f.to_bits());
let catalog = Catalog::from_json(
&serde_json::json!({
"data_version": i64::from(horizon_world::DATA_VERSION),
"types": types.iter().map(|(id, effects)| serde_json::json!({
"id": id,
"message_id": id.trim_start_matches("minecraft:"),
"exhaustion": bits(0.1),
"scaling": "when_caused_by_living_non_player",
"effects": effects,
"death_message_type": "default",
})).collect::<Vec<_>>(),
}),
&serde_json::json!({}),
)
.unwrap();
for (id, (name, effects)) in types.iter().enumerate() {
assert_eq!(
hurt_sound_for(&registries, &catalog, id as i32),
horizon_client::health::hurt_sound(effects),
"{name}"
);
}
// An unknown id falls back to the codec's default effects.
assert_eq!(
hurt_sound_for(&registries, &catalog, types.len() as i32),
"minecraft:entity.player.hurt"
);
// A server-defined damage type with inline NBT effects needs no entry in
// the client's built-in catalog.
let mut custom = horizon_world::nbt::Compound::new();
custom.insert(
"effects".into(),
horizon_world::nbt::Tag::String("burning".into()),
);
registries.add(horizon_protocol::packets::configuration::RegistryData {
registry: "minecraft:damage_type".into(),
entries: vec![horizon_protocol::packets::configuration::RegistryEntry {
id: "custom:lava_splash".into(),
data: Some(horizon_world::nbt::Tag::Compound(custom)),
}],
});
assert_eq!(
hurt_sound_for(&registries, &catalog, 0),
"minecraft:entity.player.hurt_on_fire"
);
}
/// `ClientboundSoundEntityPacket`: the sound is heard where the entity it
/// names is — the local player included, which is no tracked entity — and
/// an id this level doesn't have plays nothing at all.
#[test]
fn a_sound_entity_is_heard_where_its_entity_is() {
use std::collections::HashMap;
let mut tracked = HashMap::new();
tracked.insert(
7,
super::Tracked {
kind: "minecraft:cow".to_owned(),
position: [8.5, 64.0, -3.5],
},
);
let player = Some((2, [0.5, 70.0, 0.5]));
assert_eq!(
super::entity_position(2, player, &[], &tracked),
Some([0.5, 70.0, 0.5]),
"the local player is named by Remote.entity_id"
);
assert_eq!(
super::entity_position(7, player, &[], &tracked),
Some([8.5, 64.0, -3.5]),
"another entity the server spawned"
);
assert_eq!(
super::entity_position(9, player, &[], &tracked),
None,
"getEntity is null: handleSoundEntityEvent returns"
);
assert_eq!(
super::entity_position(2, None, &[], &tracked),
None,
"a player with no position yet"
);
}
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
#[test]
fn a_predicted_throw_from_a_slot_swings_the_hand() {
use horizon_client::{
banner::Banners,
brewing::Brewing,
crafting::Recipes,
creative::Names,
enchant::Enchantments,
items::{ItemStack, Items},
menu::{Env, Input, Kind, Menu},
stations::StationRecipes,
};
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let none = &horizon_world::loot::PackOverlay::NONE;
let items = Items::catalog(&root, none).unwrap();
let recipes = Recipes::load(&root, &items, none).unwrap();
let brewing = Brewing::load(&root, &items, none);
let banners = Banners::load(&root);
let station_recipes = StationRecipes::load(&root, &items, none);
let enchantments = Enchantments::default();
let names = Names::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
let mut menu = Menu::new(Kind::Inventory);
let cobblestone = ItemStack {
item: "minecraft:cobblestone".into(),
count: 16,
damage: 0,
extra: None,
};
// The first hotbar slot of InventoryMenu.
menu.set_slot(&env, 36, Some(cobblestone));
let (sent, threw) =
super::predict_clicks(&mut menu, &env, &[(36, 0, Input::Throw)]).unwrap();
assert!(threw, "Q over a slot drops one and swings");
assert_eq!(menu.slot(36).map(|s| s.count), Some(15));
assert_eq!(sent.len(), 1);
let (_, threw) = super::predict_clicks(&mut menu, &env, &[(36, 0, Input::Pickup)]).unwrap();
assert!(!threw, "picking the stack up drops nothing");
let (_, threw) =
super::predict_clicks(&mut menu, &env, &[(-999, 1, Input::Pickup)]).unwrap();
assert!(threw, "one of the carried stack outside the window");
}
#[test]
fn placement_preview_checks_obstruction_without_changing_the_level() {
use super::DryLevel;
use horizon_core::{position::BlockPos, random::LegacyRandom};
use horizon_world::{
level::{
Behaviours, LevelAccess, PlaceContext, PlaceOutcome, Ticks, data::load_registry,
},
registry::BlockStateId,
};
struct Level {
air: BlockStateId,
stone: BlockStateId,
obstructed: bool,
loaded: bool,
ceiling: i32,
}
impl LevelAccess for Level {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
if pos == BlockPos::new(0, 0, 0) {
self.stone
} else {
self.air
}
}
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
panic!("preview must not write to the live level");
}
fn is_loaded(&self, _: BlockPos) -> bool {
self.loaded
}
fn max_y(&self) -> i32 {
self.ceiling
}
fn is_unobstructed(&self, pos: BlockPos, _: BlockStateId) -> bool {
assert_eq!(pos, BlockPos::new(0, 1, 0));
!self.obstructed
}
fn random(&mut self) -> &mut LegacyRandom {
panic!("preview must not advance the live level's random source");
}
fn game_time(&self) -> i64 {
0
}
}
// As with the LAN tests, prepared game resources are optional locally.
let resources = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !resources.join("block-behaviours.json").exists() {
return;
}
let registry = load_registry(&resources).unwrap();
let behaviours = Behaviours::load(&registry, &resources).unwrap();
let mut base = Level {
air: registry.default_state("minecraft:air").unwrap(),
stone: registry.default_state("minecraft:stone").unwrap(),
obstructed: false,
loaded: true,
ceiling: 320,
};
for item in [
"minecraft:stone",
"minecraft:oak_slab",
"minecraft:oak_door",
// GrowingPlantHeadBlock.getStateForPlacement draws a random age.
"minecraft:twisting_vines",
] {
let ctx = PlaceContext {
clicked_pos: BlockPos::new(0, 0, 0),
face: horizon_core::geometry::Direction::Up,
hit: horizon_core::geometry::Vec3::new(0.5, 1.0, 0.5),
inside: false,
yaw: 0.0,
pitch: 45.0,
sneaking: false,
item: item.into(),
can_use_game_master_blocks: false,
};
for (obstructed, loaded, ceiling) in [
(false, true, 320),
(true, true, 320),
(false, false, 320),
(false, true, 1),
] {
base.obstructed = obstructed;
base.loaded = loaded;
base.ceiling = ceiling;
let valid = !obstructed && loaded && ceiling > 1;
let mut preview = DryLevel::new(&base);
let outcome = behaviours.place_block(&mut preview, &mut Ticks::default(), &ctx);
assert_eq!(
matches!(outcome, PlaceOutcome::Placed { .. }),
valid,
"{item}, obstructed={obstructed}, loaded={loaded}, ceiling={ceiling}"
);
assert_eq!(preview.changed.is_empty(), !valid);
assert_eq!(base.block_state(BlockPos::new(0, 1, 0)), base.air);
}
}
}
#[test]
fn the_creative_sync_covers_inventory_menu_slots_5_to_45_once_each() {
// CreativeInventoryListener sends every InventoryMenu slot but the crafting ones: the
// armour (5..=8), the inventory (9..=44) and the off hand (45).
let stack = |item: &str| Some(horizon_client::items::ItemStack::new(item, 1));
let inventory: Vec<_> = (0..36)
.map(|i| stack(&format!("minecraft:item_{i}")))
.collect();
let offhand = stack("minecraft:shield");
let armor = [
stack("minecraft:diamond_helmet"),
stack("minecraft:diamond_chestplate"),
stack("minecraft:diamond_leggings"),
stack("minecraft:diamond_boots"),
];
let stacks = super::creative_sync_stacks(&inventory, &offhand, &armor);
assert_eq!(stacks.len(), super::CREATIVE_SYNCED);
let mut slots: Vec<i16> = (0..stacks.len()).map(super::creative_sync_slot).collect();
// Each entry goes to the menu slot that holds that stack.
for (i, &slot) in slots.iter().enumerate() {
let game_slot = Game::menu_slot("inventory", i32::from(slot)).unwrap();
let expected = match game_slot {
0..36 => &inventory[game_slot],
504 => &offhand,
500..504 => &armor[game_slot - 500],
_ => panic!("entry {i} went to game slot {game_slot}"),
};
assert_eq!(&stacks[i], expected, "entry {i}, menu slot {slot}");
}
slots.sort();
assert_eq!(slots, (5..=45).collect::<Vec<i16>>());
}
#[test]
fn a_server_slot_update_leaves_an_unsent_creative_click_to_be_sent() {
let stack = |item: &str| Some(horizon_client::items::ItemStack::new(item, 1));
let none = || vec![None; super::CREATIVE_SYNCED];
// Synced: nothing. A click puts a helmet on (entry 37, not sent yet), then the server
// sets hotbar slot 0 (a pickup): only that entry counts as synced.
let mut synced = none();
let mut before = none();
before[37] = stack("minecraft:diamond_helmet");
let mut now = before.clone();
now[0] = stack("minecraft:stick");
super::mark_synced(&mut synced, &before, now.clone());
assert_eq!(synced[0], now[0]);
assert_eq!(
synced[37], None,
"the helmet still differs, so the next tick sends it"
);
}
#[test]
fn every_inventory_menu_slot_is_a_game_slot_and_45_is_the_off_hand() {
// InventoryMenu: result, 2x2 grid, armour, 27 + 9 inventory slots, then the off hand.
assert_eq!(Game::menu_slot("inventory", 45), Some(504));
let mut seen = std::collections::HashSet::new();
for slot in 0..46 {
let internal = Game::menu_slot("inventory", slot);
assert!(internal.is_some_and(|i| seen.insert(i)), "slot {slot}");
}
assert_eq!(Game::menu_slot("inventory", 46), None);
}
#[test]
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
for (menu, internal, index) in [
("minecraft:generic_9x6", 18, 63),
("minecraft:generic_9x6", 19, 64),
("minecraft:generic_9x6", 0, 81),
("minecraft:generic_9x6", 8, 89),
("minecraft:generic_9x6", 353, 53),
("minecraft:generic_9x5", 8, 80),
("minecraft:generic_9x4", 8, 71),
("minecraft:generic_9x3", 8, 62),
("inventory", 504, 45),
] {
assert_eq!(
Game::menu_slot_index(menu, internal),
Some(index),
"{menu}, game slot {internal}"
);
}
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
assert_eq!(Game::menu_slot_index("unknown", 0), None);
}
/// `FireworkRocketItem.use` is SUCCESS while gliding: the main hand's
/// rocket ends the click and the off hand's shield is not raised (#3424).
/// On foot it passes, so the off hand still gets its turn.
#[test]
fn a_rocket_used_while_gliding_ends_the_click() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
eprintln!("skipping: no prepared resources in {}", root.display());
return;
}
let registry = horizon_world::level::data::load_registry(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, &registry).unwrap();
let items = horizon_client::items::Items::load(
&root,
&registry,
&facts,
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap();
let rocket = items.get("minecraft:firework_rocket").unwrap();
assert!(super::use_ends_click(rocket, true), "gliding");
assert!(!super::use_ends_click(rocket, false), "on foot");
// Other items answer the same whether or not the player glides.
let shield = items.get("minecraft:shield").unwrap();
let stick = items.get("minecraft:stick").unwrap();
for gliding in [false, true] {
assert!(super::use_ends_click(shield, gliding));
assert!(!super::use_ends_click(stick, gliding));
// FoodOnAStickItem.use passes on a client: no swing while the boost cools down.
for rod in [
"minecraft:carrot_on_a_stick",
"minecraft:warped_fungus_on_a_stick",
] {
let rod = items.get(rod).unwrap();
assert!(!super::use_ends_click(rod, gliding), "{}", rod.id);
}
}
}
/// `ClientPacketListener.handleGameEvent`: verify that the game event packet
/// types for arrow hit, pufferfish sting and limited crafting match vanilla.
#[test]
fn game_event_constants_and_types_match_vanilla() {
use horizon_protocol::packets::play::GameEvent;
// Event types from ClientboundGameEventPacket:
// 6 = PLAY_ARROW_HIT_SOUND, 7 = RAIN_LEVEL_CHANGE, 8 = THUNDER_LEVEL_CHANGE,
// 9 = PUFFER_FISH_STING, 11 = IMMEDIATE_RESPAWN, 12 = LIMITED_CRAFTING.
let arrow = GameEvent {
event: 6,
param: 0.0,
};
let rain = GameEvent {
event: 7,
param: 0.5,
};
let thunder = GameEvent {
event: 8,
param: 0.2,
};
let puffer = GameEvent {
event: 9,
param: 0.0,
};
let respawn = GameEvent {
event: 11,
param: 1.0,
};
let crafting = GameEvent {
event: 12,
param: 1.0,
};
assert_eq!(arrow.event, 6);
assert_eq!(rain.event, 7);
assert_eq!(thunder.event, 8);
assert_eq!(puffer.event, 9);
assert_eq!(respawn.event, 11);
assert_eq!(crafting.event, 12);
assert!(crafting.param == 1.0);
}
}