diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 755a00f6f..f05be393a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1584,6 +1584,7 @@ struct Game<'a> { guest_use: Option, /// Level events of this tick for the guests, and the guest acting now. lan_events: Vec<(i32, [i32; 3], i32, Option)>, + lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>, /// Block events (position, b0, b1, state) and entity events (id, event, /// position) for the guests. lan_block_events: Vec<([i32; 3], i32, i32, u32)>, @@ -12477,6 +12478,7 @@ impl<'a> Game<'a> { forced_hit: None, guest_use: None, lan_events: Vec::new(), + lan_cushion_breaks: Vec::new(), lan_block_events: Vec::new(), lan_entity_events: Vec::new(), lan_critical_hits: Vec::new(), diff --git a/crates/horizon-client/src/game/cushions.rs b/crates/horizon-client/src/game/cushions.rs index 0847321d6..30af72104 100644 --- a/crates/horizon-client/src/game/cushions.rs +++ b/crates/horizon-client/src/game/cushions.rs @@ -348,11 +348,15 @@ impl Game<'_> { let Some(state) = self.pack.assets.registry.default_state(&name) else { return; }; - let at = [ - c.position.x.floor() as i32, - c.position.y.floor() as i32, - c.position.z.floor() as i32, - ]; + let p = c.position; + // Entity.getY(2/3) is relative to the cushion's quarter-block height. + let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z]; + // Guest actions temporarily take self.lan; flush once the hub is back. + // Queue before resolving a sprite: a dedicated server has no renderer. + if self.audio.capture.is_some() { + self.lan_cushion_breaks.push((state, particle_at)); + } + let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]; let Some((sprite, tint)) = self.particle_sprite(state, at) else { return; }; @@ -361,14 +365,8 @@ impl Game<'_> { sprite, tint, ); - let p = c.position; - self.particles.send_particles( - &options, - [p.x, p.y + 0.666_666_7, p.z], - [0.25, 0.0625, 0.25], - 0.05, - 10, - ); + self.particles + .send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10); } /// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100. diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 63aea7e9f..75500ba79 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3; use horizon_protocol::{ crypto::offline_uuid, entity_data::{Value, serializer}, + packets::play::{self, Clientbound as C}, }; use std::{ collections::HashMap, @@ -665,6 +666,7 @@ impl Game<'_> { self.audio.capture = None; self.lan_events.clear(); self.lan_critical_hits.clear(); + self.lan_cushion_breaks.clear(); self.streaming.extra.clear(); if announce { self.system_message(Text::translate( @@ -1229,6 +1231,33 @@ impl Game<'_> { /// Send the tick's level events, sounds and mob animations. fn lan_flush_effects(&mut self, host: &mut LanHost) { let critical_hits = std::mem::take(&mut self.lan_critical_hits); + for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) { + if let Some(id) = host + .hub + .registry_id("minecraft:particle_type", "minecraft:block") + { + host.hub.broadcast_near( + at, + 32.0, + &C::LevelParticles(play::LevelParticles { + particle: horizon_protocol::entity_data::Particle { + id, + data: Some(horizon_protocol::entity_data::ParticleData::State( + state.0 as i32, + )), + }, + override_limiter: false, + always_show: false, + pos: at, + dist: [0.25, 0.0625, 0.25], + max_speed: [0.05; 3], + count: 10, + randomization: 0, + }), + None, + ); + } + } for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) { if kind == 2001 { host.broken diff --git a/tests/test_cushion_particles.py b/tests/test_cushion_particles.py new file mode 100644 index 000000000..adfbf2e00 --- /dev/null +++ b/tests/test_cushion_particles.py @@ -0,0 +1,87 @@ +"""Cushion particles must reach clients even when the server has no sprite atlas.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(shutil.which("rustc"), "requires Rust") +class CushionParticlesTests(unittest.TestCase): + def test_headless_server_records_break_effect_before_sprite_lookup(self): + source = (ROOT / "crates/horizon-client/src/game/cushions.rs").read_text() + start = source.index(" fn cushion_break_particles(") + end = source.index(" /// `BlockAttachedEntity.tick`", start) + fixture = r''' +#![allow(dead_code)] +#[derive(Clone, Copy)] +struct Vec3 { x: f64, y: f64, z: f64 } +struct Cushion { position: Vec3 } +impl Cushion { fn color_name(&self) -> &str { "blue" } } +struct Registry; +impl Registry { + fn default_state(&self, name: &str) -> Option { + assert_eq!(name, "minecraft:blue_wool"); Some(123) + } +} +struct Assets { registry: Registry } +struct Pack { assets: Assets } +struct Audio { capture: Option<()> } +mod horizon_client { pub mod particles { + pub enum ParticleType { Block } + pub struct ParticleOptions; + impl ParticleOptions { + pub fn block(_: ParticleType, _: String, _: u32) -> Self { Self } + } +} } +#[derive(Default)] +struct Particles { count: u32 } +impl Particles { + fn send_particles(&mut self, _: &horizon_client::particles::ParticleOptions, + at: [f64; 3], spread: [f64; 3], speed: f64, count: u32) { + assert!((at[1] - (10.0 + 1.0/6.0)).abs() < 1e-12); + assert_eq!(spread, [0.25, 0.0625, 0.25]); + assert_eq!(speed, 0.05); assert_eq!(count, 10); self.count += count; + } +} +struct Game { + pack: Pack, audio: Audio, particles: Particles, headless: bool, + lan_cushion_breaks: Vec<(u32, [f64; 3])>, +} +impl Game { + fn particle_sprite(&self, _: u32, _: [i32; 3]) -> Option<(String, u32)> { + (!self.headless).then(|| ("block/blue_wool".into(), 0xffffff)) + } +METHOD +} +fn main() { + let cushion = Cushion { position: Vec3 { x: 1.5, y: 10.0, z: 2.5 } }; + for headless in [true, false] { + for serving in [true, false] { + let mut game = Game { + pack: Pack { assets: Assets { registry: Registry } }, + audio: Audio { capture: serving.then_some(()) }, + particles: Particles::default(), headless, lan_cushion_breaks: vec![], + }; + game.cushion_break_particles(&cushion); + assert_eq!(game.lan_cushion_breaks.len(), usize::from(serving)); + if serving { + let (state, at) = game.lan_cushion_breaks[0]; + assert_eq!(state, 123); + assert_eq!([at[0], at[2]], [1.5, 2.5]); + assert!((at[1] - (10.0 + 1.0/6.0)).abs() < 1e-12); + } + assert_eq!(game.particles.count, if headless { 0 } else { 10 }); + } + } +} +'''.replace("METHOD", source[start:end]) + with tempfile.TemporaryDirectory(prefix="cushion-particles-") as directory: + path = Path(directory) + (path / "main.rs").write_text(fixture) + subprocess.run(["rustc", "--edition=2024", str(path / "main.rs"), "-o", str(path / "probe")], + check=True, capture_output=True, text=True) + result = subprocess.run([str(path / "probe")], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index c508a67d6..6847f8322 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -7235,9 +7235,15 @@ def check_cushions(host, guest, out): assert player('status').get('riding') is None, seen player('command', command='gamemode creative') player.raw('look-at', at[0], at[1] + .125, at[2]) + before = {g.name: g('status').get('particles', {}).get('block', 0) for g in (host, guest)} player.raw('mouse', 'left', 'click') player.wait(10) assert not cushions(host) and not cushions(player), seen + seen[label]['break_particles'] = { + g.name: g('status').get('particles', {}).get('block', 0) - before[g.name] + for g in (host, guest) + } + assert all(n == 10 for n in seen[label]['break_particles'].values()), seen return seen