Merge pull request #655 from ludovic111/fix/client-server-packets

Network client: apply block entity data, difficulty, spawn, chunk centre, tick rate and animations from the server
This commit is contained in:
Ludovic Marie
2026-10-17 10:30:53 +02:00
committed by GitHub
4 changed files with 244 additions and 9 deletions
+39 -1
View File
@@ -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"
);
}
}
+182 -8
View File
@@ -132,6 +132,8 @@ pub(super) struct Remote {
decode_errors: u32,
clock: Option<i32>,
clock_rate: f32,
/// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket).
chunk_center: (i32, i32),
}
fn face_direction(face: [i32; 3]) -> u8 {
@@ -308,7 +310,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(())
}
@@ -331,6 +336,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();
@@ -581,6 +587,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();
@@ -657,6 +665,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();
@@ -923,6 +1053,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);
@@ -938,6 +1077,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),
@@ -1221,7 +1362,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)
@@ -1556,7 +1697,29 @@ 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),
// 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
@@ -1888,10 +2051,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;
@@ -1902,7 +2070,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();
@@ -2013,12 +2181,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;
@@ -2056,7 +2230,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))
+8
View File
@@ -204,6 +204,14 @@ fn save_icon(file: &Path, address: &str, icon: Option<String>) {
}
}
/// `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<String> {
match servers.get(index) {
@@ -577,6 +577,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<Stack> => Slot });
packet!(SetDefaultSpawnPosition { pos: GlobalPos => GlobalPos, yaw: f32 => Float, pitch: f32 => Float });
@@ -1066,6 +1074,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,
});
@@ -1573,6 +1583,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);