From cfcf69f0b98c42a8fc41e336fd6d7ec1c9328b41 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:29:17 +0200 Subject: [PATCH 1/2] Network client: apply the block entity data, difficulty, spawn, chunk centre, tick rate, animations and server data the server sends With single player on its server thread, the window is the server's client and dropped several packets the in-frame game never needed: - ClientboundBlockEntityDataPacket and the block entities of chunk packets: the block entity of that type loads the tag (signs, spawners), as handleBlockEntityData and LevelChunk.replaceWithPacketData do. - ClientboundChangeDifficultyPacket: the level data's difficulty and lock. - ClientboundSetDefaultSpawnPositionPacket: the respawn data spawn compasses point at. - ClientboundSetChunkCacheCenterPacket: ClientChunkCache's view centre; columns outside the storage (the server's radius + 3) are ignored or dropped, as ClientChunkCache.Storage does. - ClientboundAnimatePacket: wake up, and the critical and magic critical hit emitters. - ClientboundTickingStatePacket / ClientboundTickingStepPacket (new in the protocol crate): the level's TickRateManager. A frozen level holds its time and non-player entities, and the server's rate only ever slows the client timer down (Minecraft.getTickTargetMillis). - ClientboundServerDataPacket: the server list entry keeps a valid icon (ServerList.saveSingleServer); single player has no entry. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/clock.rs | 40 +++- crates/horizon-client/src/game/remote.rs | 195 +++++++++++++++++++- crates/horizon-client/src/multiplayer.rs | 8 + crates/horizon-protocol/src/packets/play.rs | 15 ++ 4 files changed, 249 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/clock.rs b/crates/horizon-client/src/clock.rs index 865a348bc..accf07fc9 100644 --- a/crates/horizon-client/src/clock.rs +++ b/crates/horizon-client/src/clock.rs @@ -298,6 +298,9 @@ pub struct FixedStep { tick_times: [i64; SAMPLES], aggregated: i64, tick_count: i64, + /// A server's client: its level's tick rate only slows the timer down + /// (`Minecraft.getTickTargetMillis`), since the server runs the ticks. + client: bool, } impl Default for FixedStep { @@ -312,11 +315,20 @@ impl Default for FixedStep { tick_times: [0; SAMPLES], aggregated: 0, tick_count: 0, + client: false, } } } impl FixedStep { + /// The clock of a client connected to a server: the `ClientLevel`'s + /// `TickRateManager`, set by the server's ticking packets. + pub fn client() -> Self { + Self { + client: true, + ..Self::default() + } + } fn now_ms(&self) -> i64 { self.now.as_millis() as i64 } @@ -341,9 +353,14 @@ impl FixedStep { } let runs = self.manager.base.runs_normally(); let mspt = self.manager.base.milliseconds_per_tick(); + let client = self.client; let ticks = self .timer - .advance_game_time(now_ms, |default| if runs { mspt } else { default }); + .advance_game_time(now_ms, |default| match (runs, client) { + (true, true) => default.max(mspt), + (true, false) => mspt, + (false, _) => default, + }); ticks.clamp(0, 10) as u32 } /// Restart pacing from now (after loading, so no backlog replays). @@ -400,6 +417,10 @@ impl FixedStep { pub fn set_frozen(&mut self, frozen: bool) { self.manager.base.set_frozen(frozen); } + /// `TickRateManager.setFrozenTicksToRun` (ClientboundTickingStepPacket). + pub fn set_frozen_ticks_to_run(&mut self, ticks: i32) { + self.manager.base.set_frozen_ticks_to_run(ticks); + } pub fn sprinting(&self) -> bool { self.manager.is_sprinting() } @@ -484,4 +505,21 @@ mod tests { m.base.tick(); assert!(!m.base.runs_normally()); } + #[test] + fn a_servers_tick_rate_only_slows_its_client_down() { + let quarter = std::time::Duration::from_millis(250); + let mut client = FixedStep::client(); + client.set_rate(40.0); + assert_eq!(client.advance(quarter), 5, "still 20 a second"); + let mut slow = FixedStep::client(); + slow.set_rate(10.0); + assert_eq!(slow.advance(quarter * 2), 5, "10 a second"); + let mut local = FixedStep::default(); + local.set_rate(40.0); + assert_eq!( + local.advance(quarter), + 10, + "the integrated loop runs at the rate" + ); + } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 6fc342a96..a30f1ce29 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -132,6 +132,8 @@ pub(super) struct Remote { decode_errors: u32, clock: Option, clock_rate: f32, + /// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket). + chunk_center: (i32, i32), } fn face_direction(face: [i32; 3]) -> u8 { @@ -307,7 +309,10 @@ impl Game<'_> { decode_errors: 0, clock: None, clock_rate: 1.0, + chunk_center: (0, 0), }); + // The level's TickRateManager follows the server's ticking packets. + self.clock = horizon_client::clock::FixedStep::client(); Ok(()) } @@ -330,6 +335,7 @@ impl Game<'_> { None => false, }; horizon_client::crash::clear_detail("Server"); + self.clock = Default::default(); self.debug_charts.reset(); self.tab_list.reset(); self.score.board = Default::default(); @@ -580,6 +586,8 @@ impl Game<'_> { r.entities.clear(); r.lerps.clear(); r.pending_items.clear(); + // A new ClientLevel: a new chunk storage, centred on 0, 0. + r.chunk_center = (0, 0); } if let Some(renderer) = self.renderer.as_mut() { renderer.clear_sections(); @@ -656,6 +664,128 @@ impl Game<'_> { } Ok(chunk) } + /// `ClientChunkCache.Storage.inRange`: within the storage around the + /// view centre, `calculateStorageRange` (the server's radius, at least + /// two, plus three) chunks each way. + 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: Vec<(i32, i32)> = self + .world + .chunks + .keys() + .copied() + .filter(|&(x, z)| !self.remote_chunk_in_range(x, z)) + .collect(); + for (x, z) in gone { + 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() { + for sy in 0..SECTIONS as i32 { + renderer.remove((x, sy, z)); + } + } + } + } + /// `handleBlockEntityData`: the block entity at `pos`, when it is of the + /// packet's type, loads the tag (`loadWithComponents`); an empty tag + /// changes nothing. + 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; + }; + let Some(entity) = self.world.block_entities.get(&key) else { + return; + }; + let Tag::Compound(fields) = tag 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((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) { + self.world.block_entities.insert(key, loaded); + // 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); + } + } + /// `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; + } + 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. fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) { let name = self.res.facts.get(state).name.clone(); @@ -921,6 +1051,15 @@ impl Game<'_> { }) .clamp(-90.0, 90.0); } + // ClientChunkCache.replaceWithPacketData: a column outside the + // storage around the view centre is ignored. + C::LevelChunkWithLight(p) if !self.remote_chunk_in_range(p.x, p.z) => { + log::warn!( + "Ignoring chunk since it's not in the view range: {}, {}", + p.x, + p.z + ); + } C::LevelChunkWithLight(p) => match self.remote_chunk(&p) { Ok(chunk) => { let (x, z) = (p.x, p.z); @@ -936,6 +1075,8 @@ impl Game<'_> { ); let state = self.world.block(pos.0, pos.1, pos.2); self.sync_block_entity(pos, state); + // LevelChunk.replaceWithPacketData: the block entity loads its update tag. + self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag); } } Err(e) => log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z), @@ -1220,7 +1361,7 @@ impl Game<'_> { m.health = 0.0; } } - C::Animate(p) if p.action == 0 => {} + C::Animate(p) => self.remote_animate(p.id, p.action), C::UpdateMobEffect(p) => self.remote_effect(&p), C::RemoveMobEffect(p) => { if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) @@ -1549,7 +1690,34 @@ impl Game<'_> { 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), + // handleChangeDifficulty: the level data's difficulty and lock. + C::ChangeDifficulty(p) => { + self.ui.difficulty = p.difficulty.clamp(0, 3) as u8; + self.level.difficulty_locked = p.locked; + } + // 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()), + // 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::SetActionBarText(p) | C::SetTitleText(p) => { let lang = self .remote @@ -1881,10 +2049,15 @@ impl Game<'_> { /// One client tick while connected (Minecraft.tick with a ClientLevel). pub(super) fn tick_remote(&mut self) -> Result<()> { self.ticks += 1; - self.world.game_time += 1; - let rate = self.remote.as_ref().map_or(1.0, |r| r.clock_rate); - if rate > 0.0 { - self.world.day_time += rate.round() as i64; + // Minecraft.tick: TickRateManager.tick; ClientLevel.tick advances + // the time only while the level runs normally. + let running = self.clock.world_runs(); + if running { + self.world.game_time += 1; + let rate = self.remote.as_ref().map_or(1.0, |r| r.clock_rate); + if rate > 0.0 { + self.world.day_time += rate.round() as i64; + } } // Weather levels drift toward their targets between server updates. let w = &mut self.world.weather; @@ -1895,7 +2068,7 @@ impl Game<'_> { } // ClientLevel.tick: the border's size change. self.border.tick(); - self.tick_remote_entities(); + self.tick_remote_entities(running); // ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells. self.tick_block_entity_anims(); self.tick_hand(); @@ -2005,12 +2178,18 @@ impl Game<'_> { self.send(S::ClientTickEnd(Empty)); Ok(()) } - fn tick_remote_entities(&mut self) { + /// ClientLevel.tickEntities; a frozen level only ticks the players + /// (`TickRateManager.isEntityFrozen`). + fn tick_remote_entities(&mut self, running: bool) { let Some(r) = self.remote.as_mut() else { return; }; let mut lerps = std::mem::take(&mut r.lerps); + let players = &r.level_players; for m in &mut self.mobs { + if !running && !players.contains_key(&(m.id as i32)) { + continue; + } m.previous = m.position; m.previous_yaw = m.yaw; m.previous_body_yaw = m.body_yaw; @@ -2048,7 +2227,7 @@ impl Game<'_> { horizon_client::mobs::rabbit::post_tick(m); } } - for d in &mut self.dropped { + for d in self.dropped.iter_mut().filter(|_| running) { d.previous = d.position; d.age += 1; if let Some(l) = lerps.get_mut(&(d.id as i32)) diff --git a/crates/horizon-client/src/multiplayer.rs b/crates/horizon-client/src/multiplayer.rs index 60a5b2277..2228d32a2 100644 --- a/crates/horizon-client/src/multiplayer.rs +++ b/crates/horizon-client/src/multiplayer.rs @@ -204,6 +204,14 @@ fn save_icon(file: &Path, address: &str, icon: Option) { } } +/// `ClientPacketListener.handleServerData`: a valid icon the joined server +/// sends replaces the one its list entries keep. +pub fn save_server_icon(saves: &Path, address: &str, png: &[u8]) { + if icon_pixels(png).is_some() { + save_icon(&servers_file(saves), address, Some(crate::lan::base64(png))); + } +} + /// The address of list row `index`: the saved servers, then the LAN games. fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option { match servers.get(index) { diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 1459f7d9f..cecbc098d 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -574,6 +574,14 @@ packet!( ); packet!(Text { text: Tag => Nbt }); packet!(SetChunkCacheCenter { x: i32 => VarInt, z: i32 => VarInt }); +packet!( + /// ClientboundTickingStatePacket: the tick rate and whether the level is frozen. + TickingState { rate: f32 => Float, frozen: bool => Bool } +); +packet!( + /// ClientboundTickingStepPacket: the ticks a frozen level runs (`/tick step`). + TickingStep { steps: i32 => VarInt } +); packet!(Distance { distance: i32 => VarInt }); packet!(SetCursorItem { item: Option => Slot }); packet!(SetDefaultSpawnPosition { pos: GlobalPos => GlobalPos, yaw: f32 => Float, pitch: f32 => Float }); @@ -1062,6 +1070,8 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { TabList(TabList) = TAB_LIST, TakeItemEntity(TakeItemEntity) = TAKE_ITEM_ENTITY, TeleportEntity(TeleportEntity) = TELEPORT_ENTITY, + TickingState(TickingState) = TICKING_STATE, + TickingStep(TickingStep) = TICKING_STEP, UpdateMobEffect(UpdateMobEffect) = UPDATE_MOB_EFFECT, UpdateTags(UpdateTags) = UPDATE_TAGS, }); @@ -1523,6 +1533,11 @@ mod tests { relatives: 0, on_ground: true, }), + Clientbound::TickingState(TickingState { + rate: 40.0, + frozen: true, + }), + Clientbound::TickingStep(TickingStep { steps: 7 }), ]; for p in packets { round_trip(p, Clientbound::encode, Clientbound::decode); From aea0dc4c93216951ff793dc948d0a601ead2dd69 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:58:03 +0200 Subject: [PATCH 2/2] Network client: keep #656's ChangeDifficulty handler #656 handles ClientboundChangeDifficultyPacket and refreshes the World Options screen too; this branch's own arm became unreachable. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index eb13495d7..431f4afde 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1707,11 +1707,6 @@ impl Game<'_> { self.drop_remote_chunks_out_of_range(); } C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag), - // handleChangeDifficulty: the level data's difficulty and lock. - C::ChangeDifficulty(p) => { - self.ui.difficulty = p.difficulty.clamp(0, 3) as u8; - self.level.difficulty_locked = p.locked; - } // handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at. C::SetDefaultSpawnPosition(p) => { let [x, y, z] = p.pos.pos;