diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index 1a7a4d25c..48e2e57d4 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -42,8 +42,9 @@ log = "0.4" chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] } flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] } horizon-theme = { path = "../horizon-theme" } -# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md). -horizon-telemetry = { path = "../horizon-telemetry", default-features = false } +# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is +# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`). +horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] } horizon-bench = { path = "../horizon-bench" } arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] } zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index a25d9f537..463d51437 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -21,6 +21,8 @@ pub enum ItemPose { Trident { ticks_used: f32, }, + /// Riptide's spin (`isAutoSpinAttack`): the item held up like a fork. + SpinAttack, Bow { ticks_used: f32, }, @@ -67,6 +69,12 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) - if matches!(pose, ItemPose::Using) { return m; } + if matches!(pose, ItemPose::SpinAttack) { + return m + * Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3)) + * Mat4::from_rotation_y((invert * 65.0f32).to_radians()) + * Mat4::from_rotation_z((invert * -85.0f32).to_radians()); + } let charging = match pose { ItemPose::Bow { ticks_used } => { let power = ticks_used / 20.0; diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index bad635c5c..0c1efc251 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -902,6 +902,8 @@ struct WorldRest { behaviours: Behaviours, player_arm: Option, player_type: Option>, + /// `SpinAttackEffectModel` (Riptide's spinning boxes). + spin_attack: Option, stations: horizon_client::stations::StationData, } @@ -977,6 +979,9 @@ impl Resources { fn player_type(&self) -> &Option> { &self.rest().player_type } + fn spin_attack(&self) -> &Option { + &self.rest().spin_attack + } fn stations(&self) -> &horizon_client::stations::StationData { &self.rest().stations } @@ -3917,6 +3922,7 @@ impl Game<'_> { } let riding = self.riding.is_some(); let ghast_collisions = self.happy_ghast_player_collisions(); + let spin_targets = self.spin_targets(); let Some(player) = self.player.as_mut() else { // A dedicated server has no local player: the world still runs. if self.options.dedicated.is_some() { @@ -3994,6 +4000,7 @@ impl Game<'_> { .any(|s| immune.is_some_and(|t| t.contains(&*s.item))); // The dimension's FAST_LAVA: the Nether's lava pushes harder. player.fluids.fast_lava = self.dim == Dim::Nether; + player.spin_targets = spin_targets; let grounded = player.ground.on_ground; let before = ( grounded, @@ -4016,6 +4023,7 @@ impl Game<'_> { } } let events = std::mem::take(&mut player.events); + let spin_hit = player.spin_hit.take(); let glider_wear = std::mem::take(&mut player.glider_wear); if glider_wear > 0 && !self.creative @@ -4034,6 +4042,7 @@ impl Game<'_> { self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5; let crouching = player.pose == horizon_client::pose::Pose::Crouching; self.tick_avatar(before, riding); + self.end_spin_attack(spin_hit); self.record_combat(&events); for event in events { match event { @@ -6761,7 +6770,13 @@ impl Game<'_> { /// enchantments. pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool) { let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage); - let damage = hand_damage + self.effect_attack_bonus(); + // Riptide's spin replaces the attack damage attribute. + let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg); + let damage = if spin > 0.0 { + spin + } else { + hand_damage + self.effect_attack_bonus() + }; let scale = self.attack_strength_scale(); let mut amount = damage * (0.2 + scale * scale * 0.8); // Item.getAttackDamageBonus (a mace's smash), before the critical hit. @@ -6889,6 +6904,14 @@ impl Game<'_> { return false; } } + self.attack_mob(index); + // Minecraft.startAttack swings after the attack. + self.swing_arm(); + true + } + /// `Player.attack` on a mob: the damage, knockback, sweep, particles and + /// sounds. + pub(super) fn attack_mob(&mut self, index: usize) { let target_kind = self.mobs[index].kind.id.clone(); let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:")); let (amount, scale, crit) = self.melee_damage(&target_kind); @@ -6906,7 +6929,6 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0); } - self.swing_arm(); // LivingEntity.setLastHurtMob (tamed wolves join in). self.combat.last_hurt_mob = Some((self.mobs[index].id, self.world.game_time)); let old_health = self.mobs[index].health; @@ -6999,7 +7021,6 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0); } - true } /// ServerExplosion: rays from a 16^3 grid lose (resistance + 0.3) * 0.3 per step. /// A mob explosion (`Level.ExplosionInteraction.MOB`: drop decay with the @@ -8499,6 +8520,10 @@ impl Game<'_> { } let shown = self.hands_to_render(); let used_hand = self.used_hand(); + let spinning = self + .player + .as_ref() + .is_some_and(|p| p.auto_spin_attack_ticks > 0); for (i, (hand, item)) in Hand::BOTH .into_iter() .zip(self.visible_hand_items()) @@ -8618,6 +8643,7 @@ impl Game<'_> { ItemPose::Using } } + _ if spinning => ItemPose::SpinAttack, _ => ItemPose::Swing, }; let m = to_world @@ -10949,6 +10975,10 @@ impl ApplicationHandler for Game<'_> { WindowEvent::RedrawRequested => { self.observe_fullscreen(); let now = Instant::now(); + let frame_cpu = self + .options + .spikes + .and_then(|_| horizon_client::spans::thread_cpu()); let elapsed = now.duration_since(self.previous); self.frame_ticks = elapsed.as_secs_f32() * 20.0; self.previous = now; @@ -10978,6 +11008,8 @@ impl ApplicationHandler for Game<'_> { } let updated = Instant::now(); self.track_toggle_screens(); + // For the "I'm playing" ping: an atomic, sent on when it changes. + horizon_client::heartbeat::set_lan(self.is_published()); let steps = self.clock.advance(elapsed); for _ in 0..steps { let started = Instant::now(); @@ -11015,15 +11047,16 @@ impl ApplicationHandler for Game<'_> { let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0; let spans = horizon_client::spans::take(); if cpu > threshold { - let list: Vec = spans - .iter() - .filter(|(_, ms)| *ms >= 0.5) - .map(|(n, ms)| format!("{n} {ms:.1}")) - .collect(); + // The render thread's own CPU time in the frame: + // far below the wall time, the frame waited. + let worked = horizon_client::spans::thread_cpu() + .zip(frame_cpu) + .map_or_else(String::new, |(end, start)| { + format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0) + }); log::info!( - "Horizon: slow frame {cpu:.1} ms ({} ticks): {}", - steps, - list.join(", ") + "Horizon: slow frame {cpu:.1} ms{worked} ({steps} ticks): {}", + horizon_client::spans::describe(&spans) ); } } @@ -12070,6 +12103,7 @@ fn load_world_rest( player_type: perspective::player_type(root), player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main") .and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")), + spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"), }) } @@ -12775,6 +12809,23 @@ pub fn run() -> Result<()> { if options.dedicated.is_some() { // Before any worker starts: on Linux they go behind the server thread. horizon_threads::put_helpers_behind(); + } else { + // The render thread, like the server thread, ahead of the workers on + // macOS and Windows. While terrain loads, the worker pools (two with + // single player's server thread) and other programs outnumber the + // cores, and a frame that gave its core up (in a GPU submit) waited + // 10 to 200 ms to get one back. + horizon_threads::favour_current(); + } + // Someone is playing: the anonymous ping behind the launcher's "N playing now". Not for a + // server, a benchmark or a run that quits by itself. + if options.dedicated.is_none() + && options.benchmark.is_none() + && !options.offscreen + && options.frames.is_none() + && options.world_frames.is_none() + { + horizon_client::heartbeat::start(); } let started = Instant::now(); horizon_client::spans::enable(options.spikes.is_some()); diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 6cbbf5007..1dfe0c1da 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -888,7 +888,7 @@ impl Game<'_> { "chunks": self.world.chunks.len(), "mobs": self.mobs.len(), "block_entities": self.world.block_entities.len(), - "pending_chunks": self.streaming.pending() + self.pending_uploads.len(), + "pending_chunks": self.streaming.pending() + self.pending_uploads.len() + self.remote_backlog(), "generated_chunks": self.streaming.generated_total, "meshed_sections": self.streaming.meshed_total, "streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::>(), diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 78efc708d..d36e0adec 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2107,15 +2107,22 @@ impl Game<'_> { let from = pl.position; self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false); } + // To where the pearl started its last tick (oldPosition). + let to = p.previous; if let Some(pl) = self.player.as_mut() { - pl.position = at; + pl.position = to; pl.velocity = Vec3::ZERO; pl.fall_distance = 0.0; + pl.teleported = true; } - self.previous_position = at; + self.previous_position = to; self.hurt_player_by("minecraft:ender_pearl", 5.0); - self.audio - .play("minecraft:entity.player.teleport", Some(atp), 1.0, 1.0); + self.audio.play( + "minecraft:entity.player.teleport", + Some([to.x, to.y, to.z]), + 1.0, + 1.0, + ); } } (ProjectileKind::SmallFireball, hit) => match hit { diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index f9ea1262b..548a69e3f 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -10,7 +10,7 @@ use horizon_client::{ self, Lan, MAX_PLAYERS, NetConfig, chunks::ChunkEncoder, data::SyncData, - entity::EntityState, + entity::{EntityState, States}, hub::{HostEvent, HostState, Hub, HubConfig, TickCtx}, playerdata, }, @@ -69,6 +69,9 @@ pub(super) struct LanHost { /// Guests' commands waiting for the hub to be back in `self.lan`, where /// a command can reach every player (game/lan_actions.rs). pub commands: Vec, + /// The entities the guests track, described again every tick + /// (`lan_entities`) over last tick's states. + states: lan::entity::States, } /// The player who owns an integrated server's world: a guest whose state is @@ -136,8 +139,7 @@ fn baby_index(kind: &str) -> Option { /// A mob's synched data as ServerEntity sends it (only the values that differ /// from the defaults the client constructs), with vanilla's entry indices: /// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses. -fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> { - let mut meta = Vec::new(); +fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) { let kind = m.kind.id.as_str(); if m.fire_ticks > 0 { meta.push((0, serializer::BYTE, Value::Byte(1))); @@ -280,14 +282,16 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> { } _ => {} } - meta } /// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand, /// off hand, feet, legs, chest, head, body, saddle). -fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option)> { +fn mob_equipment( + m: &Mob, + hub: &mut Hub, + out: &mut Vec<(u8, Option)>, +) { let e = &m.extra; - let mut out = Vec::new(); let slots = [ (0u8, &e.main_hand), (1, &e.off_hand), @@ -316,7 +320,6 @@ fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option { waypoints: Default::default(), waypoint_dead: Default::default(), commands: Vec::new(), + states: Default::default(), }); self.audio.capture = Some(Vec::new()); Ok(()) @@ -740,77 +744,44 @@ impl Game<'_> { equipment, health: p.health.health(), attributes: horizon_client::lan::attributes::syncable(&p.attributes), - living_flags: living_flags(self.using), + // Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack). + living_flags: living_flags(self.using) + | if p.auto_spin_attack_ticks > 0 { 4 } else { 0 }, bed: self.sleeping, }) } /// The entities guests track: mobs, items and the rest (game/lan_entities.rs). - fn lan_entities(&self, host_entity: Option, hub: &mut Hub) -> Vec { - let mut out = Vec::with_capacity(self.mobs.len() + self.dropped.len() + self.orbs.len()); + fn lan_entities(&self, host_entity: Option, hub: &mut Hub, out: &mut States) { + out.clear(); for m in self.mobs.iter().filter(|m| !m.removed) { - let kind = m.kind.id.as_str(); - let meta = mob_meta(m, hub); - let equipment = mob_equipment(m, hub); - let attributes = self.mob_attributes(m); - let (yaw, pitch) = (m.yaw, m.head_pitch); - out.push(EntityState { - id: m.id as i32, - uuid: entity_uuid(m.id), - kind: kind.to_owned(), - position: [m.position.x, m.position.y, m.position.z], - yaw, - pitch, - head_yaw: m.head_yaw, - on_ground: m.on_ground, - velocity: [m.velocity.x, m.velocity.y, m.velocity.z], - data: 0, - meta, - equipment, - attributes, - precise: false, - }); + let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str()); + e.position = [m.position.x, m.position.y, m.position.z]; + (e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw); + e.on_ground = m.on_ground; + e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z]; + mob_meta(m, hub, &mut e.meta); + mob_equipment(m, hub, &mut e.equipment); + self.mob_attributes(m, &mut e.attributes); } for d in &self.dropped { let stack = hub.stack(&Some(d.stack.clone())); - out.push(EntityState { - id: d.id as i32, - uuid: entity_uuid(d.id), - kind: "minecraft:item".to_owned(), - position: [d.position.x, d.position.y, d.position.z], - yaw: 0.0, - pitch: 0.0, - head_yaw: 0.0, - on_ground: d.on_ground, - velocity: [d.velocity.x, d.velocity.y, d.velocity.z], - data: 0, - meta: vec![(8, serializer::ITEM_STACK, Value::Item(stack))], - equipment: Vec::new(), - attributes: Vec::new(), - precise: false, - }); + let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item"); + e.position = [d.position.x, d.position.y, d.position.z]; + e.on_ground = d.on_ground; + e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z]; + e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack))); } for o in &self.orbs { - out.push(EntityState { - id: o.id as i32, - uuid: entity_uuid(o.id), - kind: "minecraft:experience_orb".to_owned(), - position: [o.position.x, o.position.y, o.position.z], - yaw: 0.0, - pitch: 0.0, - head_yaw: 0.0, - on_ground: o.on_ground, - velocity: [o.velocity.x, o.velocity.y, o.velocity.z], - data: 0, - // ExperienceOrb.DATA_VALUE. - meta: vec![(8, serializer::INT, Value::Int(o.value as i32))], - equipment: Vec::new(), - attributes: Vec::new(), - precise: false, - }); + let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb"); + e.position = [o.position.x, o.position.y, o.position.z]; + e.on_ground = o.on_ground; + e.velocity = [o.velocity.x, o.velocity.y, o.velocity.z]; + // ExperienceOrb.DATA_VALUE. + e.meta + .push((8, serializer::INT, Value::Int(o.value as i32))); } - self.lan_object_states(host_entity, hub, &mut out); - out + self.lan_object_states(host_entity, hub, out); } /// Run the hub for this tick and apply what the guests did. @@ -832,21 +803,23 @@ impl Game<'_> { // A dedicated server runs the hub without a host player. if state.is_some() || host.hub.config.dedicated { let has_guests = !host.hub.guests.is_empty(); - let entities = if has_guests { + let mut states = std::mem::take(&mut host.states); + if has_guests { let host_entity = state.as_ref().map(|s| s.entity_id); self.lan_assign_ids(); self.lan_riding(host_entity, &mut host.hub.riding); - self.lan_entities(host_entity, &mut host.hub) + self.lan_entities(host_entity, &mut host.hub, &mut states); } else { - Vec::new() - }; + states.clear(); + } + let entities = states.as_slice(); let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); let events = { let clock_rate = self.network_clock_rate(); let mut ctx = TickCtx { world: &mut self.world, host: state.as_ref(), - entities: &entities, + entities, next_entity: &mut self.next_entity, spawn, difficulty: i32::from(self.ui.difficulty), @@ -887,7 +860,7 @@ impl Game<'_> { self.lan_log_event(event); } HostEvent::Joined(conn) => { - self.lan_guest_joined(&mut host, conn, state.as_ref(), &entities, spawn); + self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn); if self.options.dedicated.is_some() && let Some(g) = host.hub.guest(conn) { @@ -952,6 +925,7 @@ impl Game<'_> { } } } + host.states = states; } self.lan_flush_effects(&mut host); self.tick_guests(&mut host); @@ -1011,15 +985,15 @@ impl Game<'_> { g.dimension = in_place; } self.load_guest_records(host, conn); - let theirs; + let mut theirs = States::default(); let entities = if elsewhere { if let Err(e) = self.enter_level(Some(host), level) { log::error!("could not open {}: {e}", level.id()); return; } // A dedicated server has no host player. - theirs = self.lan_entities(None, &mut host.hub); - &theirs[..] + self.lan_entities(None, &mut host.hub, &mut theirs); + theirs.as_slice() } else { entities }; diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index b261e8ad2..c5cece06c 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -532,12 +532,14 @@ impl Game<'_> { g.health = m.health.health(); g.food = m.health.food.food; g.saturation = m.health.food.saturation; - // A teleport (commands) moves the guest. + // A teleport (commands, a pearl) moves the guest; a pearl's is + // sent even to where they stand (ServerPlayer.teleport). let at = [m.position.x, m.position.y, m.position.z]; m.sprinting = false; + let teleported = std::mem::take(&mut m.teleported); let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum(); g.motion = Some(Box::new(m)); - if moved > 0.0001 || turned { + if moved > 0.0001 || turned || teleported { host.hub.teleport(i, Some(at)); } } diff --git a/crates/horizon-client/src/game/lan_attributes.rs b/crates/horizon-client/src/game/lan_attributes.rs index 72d06c2ef..73942e770 100644 --- a/crates/horizon-client/src/game/lan_attributes.rs +++ b/crates/horizon-client/src/game/lan_attributes.rs @@ -83,17 +83,16 @@ impl Game<'_> { /// `LivingEntity.removeFrost`), what finalizeSpawn set, the baby speed /// modifier, its effects' modifiers, its equipment's, and what /// `/attribute` changed. - pub(super) fn mob_attributes(&self, m: &Mob) -> Vec { + pub(super) fn mob_attributes(&self, m: &Mob, out: &mut Vec) { let kind = m.kind.id.as_str(); - let mut out = Vec::new(); if let Some(a) = self.synced_attribute(kind, "minecraft:movement_speed") { - instance(&mut out, a, finalize::base_value(m, Attr::MovementSpeed)); + instance(out, a, finalize::base_value(m, Attr::MovementSpeed)); } if let Some(s) = m.extra.attributes.as_deref() { for (attr, v) in &s.bases { if let Some(a) = self.synced_attribute(kind, &format!("minecraft:{}", attr.name())) { - instance(&mut out, a, *v).base = *v; + instance(out, a, *v).base = *v; } } for x in &s.modifiers { @@ -101,7 +100,7 @@ impl Game<'_> { self.synced_attribute(kind, &format!("minecraft:{}", x.attr.name())) { add_modifier( - &mut out, + out, a, finalize::base_value(m, x.attr), AttributeModifier { @@ -122,7 +121,7 @@ impl Game<'_> { finalize::transient(m, Attr::MovementSpeed), ) { add_modifier( - &mut out, + out, a, finalize::base_value(m, Attr::MovementSpeed), AttributeModifier { @@ -150,7 +149,7 @@ impl Game<'_> { }; let start = base(attribute).unwrap_or_else(|| self.attribute_default(kind, attribute)); add_modifier( - &mut out, + out, a, start, AttributeModifier { @@ -172,10 +171,9 @@ impl Game<'_> { ("chest", e.chest.as_ref()), ("head", e.head.as_ref()), ], - &mut out, + out, ); - self.generic_attributes_of(Target::Mob(m.id), &mut out); - out + self.generic_attributes_of(Target::Mob(m.id), out); } /// An armor stand's synced instances: movement speed (its first diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 7230e323d..404d00c46 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -5,7 +5,7 @@ //! synched data that differs from the defaults), and who rides what. use super::Game; use horizon_client::lan::{ - entity::{EntityState, Riding}, + entity::{EntityState, Riding, States}, hub::Hub, }; use horizon_protocol::entity_data::{Value, serializer}; @@ -107,7 +107,7 @@ impl Game<'_> { &self, host_entity: Option, hub: &mut Hub, - out: &mut Vec, + out: &mut States, ) { for b in &self.boats { let p = b.position; @@ -268,12 +268,7 @@ impl Game<'_> { /// Primed TNT, falling blocks, projectiles, tridents, firework rockets and /// end crystals, as guests track them. - fn lan_projectile_states( - &self, - host_entity: Option, - hub: &mut Hub, - out: &mut Vec, - ) { + fn lan_projectile_states(&self, host_entity: Option, hub: &mut Hub, out: &mut States) { let owner = |who: Option| match who { Some(horizon_client::mobs::Who::Mob(id)) => id as i32, Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0), diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 31b34a9d0..7df5541b4 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -312,15 +312,10 @@ impl Game<'_> { .map_or_else(String::new, |(end, start)| { format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0) }); - let list: Vec = spans - .iter() - .filter(|(_, ms)| *ms >= 0.5) - .map(|(n, ms)| format!("{n} {ms:.1}")) - .collect(); log::info!( "Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}", self.mobs.len(), - list.join(", ") + horizon_client::spans::describe(&spans) ); } result diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs index 0fab09bde..68aabbbc6 100644 --- a/crates/horizon-client/src/game/natural_spawning.rs +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -191,7 +191,13 @@ impl NaturalLevel for GameLevel<'_, '_> { StructureSpawns::shared(&self.game.options.resources) } fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option { - nearest_player(&self.players, x, y, z).map(|p| p.distance_sqr(x, y, z)) + // The nearest player's distance (`nearest_player`), without finding + // which player it is: every spawn attempt asks. + self.players + .iter() + .filter(|p| !p.spectator) + .map(|p| p.distance_sqr(x, y, z)) + .reduce(f64::min) } fn respawn(&self) -> Option<[i32; 3]> { // The world spawn point lives in the overworld. diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index ec46120dd..bd2721648 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -312,6 +312,7 @@ impl Game<'_> { let crouching = p.pose == Pose::Crouching; let (position, flying) = (p.position, p.flying || p.fall_flying); let (fall_flying, fly_ticks, velocity) = (p.fall_flying, p.fall_fly_ticks, p.velocity); + let spinning = p.auto_spin_attack_ticks > 0; let using = self.using.map(|(_, ticks)| ticks as i32 + 1).unwrap_or(0); let using_off_hand = self .using @@ -397,6 +398,7 @@ impl Game<'_> { m.velocity = velocity; m.extra.fall_flying = fall_flying; m.extra.fall_fly_ticks = fly_ticks; + m.extra.auto_spin_attack = spinning; m.extra.main_hand_left = self.settings.main_hand_left; m.extra.using_item = using; m.extra.using_off_hand = using_off_hand; @@ -451,6 +453,7 @@ impl Game<'_> { out, ); self.player_elytra_geometry(m, camera, alpha as f32, packed, out); + self.spin_attack_geometry(m, camera, alpha as f32, packed, out); self.held_item_geometry(m, camera, alpha, packed, true, out); } /// `extractEntityInInventoryFollowsMouse`: the player in the box @@ -592,6 +595,47 @@ impl Game<'_> { out, ); } + /// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning + /// player (`SpinAttackEffectModel.setupAnim`). + fn spin_attack_geometry( + &self, + m: &Mob, + camera: glam::DVec3, + partial: f32, + light: u32, + out: &mut Vec, + ) { + if !m.extra.auto_spin_attack { + return; + } + let (Some(model), Some(rect)) = ( + self.res.spin_attack().as_ref(), + self.gui.atlas.sprites.get("entity/trident/trident_riptide"), + ) else { + return; + }; + let age = m.age as f32 + partial; + horizon_client::mobs::render_model( + model, + horizon_client::mobs::render::base_transform(m, partial, camera), + &|part| { + let mut p = part.pose; + if let Some(i) = part + .name + .strip_prefix("box") + .and_then(|i| i.parse::().ok()) + { + let angle = age * -(45.0 + f32::from(i + 1) * 5.0); + p[4] = wrap(angle).to_radians(); + } + p + }, + [rect.u0, rect.v0, rect.u1, rect.v1], + light, + 0, + out, + ); + } } fn player_preview_quads( diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 09e03e719..a5b5a5b2a 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -41,7 +41,11 @@ use horizon_protocol::{ text::{Language, format_java, plain}, }; use horizon_world::{nbt::Tag, registry::BlockStateId}; -use std::{collections::HashMap, sync::Arc, time::Instant}; +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, + time::{Duration, Instant}, +}; use winit::{event::MouseButton, keyboard::KeyCode}; /// A text component the server sent, for the HUD. @@ -162,8 +166,22 @@ pub(super) struct Remote { 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, + backlog_since: Option, } +/// 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. +const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1); +/// Events that have waited this long are all handled in the next frame, +/// whatever their cost: the packets after a slow machine's chunks never +/// wait more than a tick. +const BACKLOG_LIMIT: Duration = Duration::from_millis(50); + fn face_direction(face: [i32; 3]) -> u8 { match face { [0, -1, 0] => 0, @@ -355,6 +373,8 @@ impl Game<'_> { clock_partial: 0.0, clock_game_time: 0, chunk_center: (0, 0), + backlog: VecDeque::new(), + backlog_since: None, }); // The level's TickRateManager follows the server's ticking packets. self.clock = horizon_client::clock::FixedStep::client(); @@ -571,12 +591,34 @@ impl Game<'_> { Some(r.chat.iter().rev().take(n).map(|l| l.0.clone()).collect()) } - /// Process everything the network thread received (once per frame, like - /// the client's packet processor). + /// 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()) + } + /// 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`. pub(super) fn poll_remote(&mut self) -> Result<()> { - let Some(r) = &self.remote else { return Ok(()) }; - let events = r.session.poll(); - for event in events { + let Some(r) = self.remote.as_mut() else { + return Ok(()); + }; + let mut events = std::mem::take(&mut r.backlog); + events.extend(r.session.poll()); + let overdue = r + .backlog_since + .is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT); + let mut chunks = Duration::ZERO; + while let Some(event) = events.pop_front() { + let chunk = + matches!(&event, Event::Packet(p) if matches!(**p, C::LevelChunkWithLight(_))); + if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME { + events.push_front(event); + break; + } + let started = chunk.then(Instant::now); match event { Event::Status(s) => { if let Some(r) = self.remote.as_mut() { @@ -622,10 +664,19 @@ impl Game<'_> { } 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(()) } diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index b24f4540e..c31094596 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -122,6 +122,38 @@ impl Game<'_> { let sky = self.world.light(x, y + 1, z) >> 4 == 15; water || (self.world.weather.raining && sky) } + /// The living entities a spinning player can touch this tick, for + /// `LivingEntity.checkAutoSpinAttack` (none when not spinning). + pub(super) fn spin_targets(&self) -> Vec<(u32, Aabb)> { + let Some(p) = self + .player + .as_ref() + .filter(|p| p.auto_spin_attack_ticks > 0) + else { + return Vec::new(); + }; + // A spin moves a few blocks a tick at most. + let near = p.bounds().inflate(Vec3::new(16.0, 16.0, 16.0)); + self.mobs + .iter() + .filter(|m| m.alive() && m.bounds().intersects(near)) + .map(|m| (m.id, m.bounds())) + .collect() + } + /// `Player.doAutoAttackOnTouch`: the touched entity takes `Player.attack` + /// with the spin's damage; once the spin is over its damage is cleared. + pub(super) fn end_spin_attack(&mut self, hit: Option) { + if let Some(id) = hit + && let Some(index) = self.mobs.iter().position(|m| m.id == id) + { + self.attack_mob(index); + } + if let Some(p) = self.player.as_mut() + && p.auto_spin_attack_ticks <= 0 + { + p.auto_spin_attack_dmg = 0.0; + } + } /// `TridentItem.releaseUsing`. pub(super) fn release_trident(&mut self, ticks: u32) { if ticks < 10 { @@ -152,6 +184,9 @@ impl Game<'_> { p.velocity.y + d[1] * f64::from(strength), p.velocity.z + d[2] * f64::from(strength), ); + // Player.startAutoSpinAttack(20, 8, stack). + p.auto_spin_attack_ticks = 20; + p.auto_spin_attack_dmg = 8.0; if p.ground.on_ground { p.position.y += 1.2; } diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index da53c42ed..14c040c5c 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -4167,6 +4167,34 @@ mod tests { ); assert_eq!(surface_format(&[Rgba16Float], Bgra8UnormSrgb), Rgba16Float); } + /// A Wayland surface on a compositor with colour management, as wgpu lists it (Mesa 26.2 + /// ANV on an Intel HD 620 under weston 16): wide formats first. The pre-#1075 choice, + /// the first non-sRGB format, took `Rgba16Float` and the window looked washed out, as if + /// under a white overlay (#1007); XWayland lists no such format, which is why it looked fine. + #[test] + fn a_colour_managed_wayland_surface_still_gets_8_bit() { + use super::surface_format; + use wgpu::TextureFormat::*; + let wayland = [ + Rgba16Float, + Rgba16Unorm, + Rgb10a2Unorm, + Bgra8UnormSrgb, + Bgra8Unorm, + Rgba8UnormSrgb, + Rgba8Unorm, + ]; + assert_eq!(surface_format(&wayland, Bgra8UnormSrgb), Bgra8Unorm); + // Without Bgra8: Rgba8, still not a 16- or 10-bit format. + let without_bgra = [ + Rgba16Float, + Rgba16Unorm, + Rgb10a2Unorm, + Rgba8UnormSrgb, + Rgba8Unorm, + ]; + assert_eq!(surface_format(&without_bgra, Rgba8UnormSrgb), Rgba8Unorm); + } #[test] fn special_material_batches_preserve_quad_order_and_offsets() { let quad = |layer, id| { diff --git a/crates/horizon-client/src/heartbeat.rs b/crates/horizon-client/src/heartbeat.rs new file mode 100644 index 000000000..f73c7131f --- /dev/null +++ b/crates/horizon-client/src/heartbeat.rs @@ -0,0 +1,74 @@ +//! The anonymous "I'm playing" ping (`horizon_telemetry::live`, docs/telemetry.md) behind the +//! launcher's "N playing now". While the game runs, a thread of its own sends a random session +//! ID made here and kept only in memory, the version, the platform and whether a LAN or +//! play-with-friends world is open, every minute and at once when that last one changes. +//! It sends nothing when there is no server (`HORIZON_TELEMETRY_URL`, none by default) or +//! with `HORIZON_NO_HEARTBEAT=1` (automated runs). +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::Thread; +use std::time::Duration; + +use horizon_telemetry::live::{self, Ping}; + +/// A world is open to LAN or to friends. +static LAN: AtomicBool = AtomicBool::new(false); +static PINGER: OnceLock = OnceLock::new(); + +/// After a failure the next ping waits longer, doubling up to this. +const MAX_WAIT: Duration = Duration::from_secs(600); + +/// Starts the pings, once. Call when the game itself starts (not a dedicated server, not a +/// benchmark or an automated run). +pub fn start() { + if PINGER.get().is_some() || std::env::var_os("HORIZON_NO_HEARTBEAT").is_some() { + return; + } + let Some(endpoint) = horizon_telemetry::endpoint() else { + return; + }; + let spawned = std::thread::Builder::new() + .name("Heartbeat".into()) + .spawn(move || run(&endpoint)); + match spawned { + Ok(handle) => { + let _ = PINGER.set(handle.thread().clone()); + } + Err(e) => log::warn!("Horizon: no live count ping: {e}"), + } +} + +/// Whether a world is open to LAN or friends; a change is sent at once. +pub fn set_lan(open: bool) { + if LAN.swap(open, Ordering::Relaxed) != open + && let Some(pinger) = PINGER.get() + { + pinger.unpark(); + } +} + +fn run(endpoint: &str) { + let session = live::session_id(); + let version = crate::telemetry::version(); + let mut wait = live::INTERVAL; + let mut failed = false; + loop { + let ping = Ping::new(&session, &version, LAN.load(Ordering::Relaxed)); + match live::send(endpoint, &ping) { + Ok(()) => { + wait = live::INTERVAL; + failed = false; + } + Err(e) => { + // Said once, not every minute the server is away. + if !failed { + log::info!("Horizon: the live count ping failed ({e}); trying again later"); + } + failed = true; + wait = (wait * 2).min(MAX_WAIT); + } + } + // Woken early by `set_lan`; a spurious wake only sends one ping more. + std::thread::park_timeout(wait); + } +} diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index c1156001c..c4e8052c0 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -34,7 +34,7 @@ pub fn changed_attributes( } /// What the game says about one entity this tick. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct EntityState { pub id: i32, pub uuid: u128, @@ -61,6 +61,72 @@ pub struct EntityState { pub precise: bool, } +impl EntityState { + /// The entity `id` of type `kind` as a fresh state has it: at the origin, + /// at rest, with no data, equipment or attributes. Keeps the allocations + /// of the strings and vectors it held. + pub fn reset(&mut self, id: i32, uuid: u128, kind: &str) { + self.id = id; + self.uuid = uuid; + self.kind.clear(); + self.kind.push_str(kind); + self.position = [0.0; 3]; + (self.yaw, self.pitch, self.head_yaw) = (0.0, 0.0, 0.0); + self.on_ground = false; + self.velocity = [0.0; 3]; + self.data = 0; + self.meta.clear(); + self.equipment.clear(); + self.attributes.clear(); + self.precise = false; + } +} + +/// The states a level describes its entities with, kept from tick to tick: +/// each tick's are written over the last tick's in place, so the strings and +/// vectors in them are reused instead of being made and freed again for every +/// entity on every tick. +#[derive(Default)] +pub struct States { + list: Vec, + len: usize, +} + +impl States { + /// Start a new tick's states. + pub fn clear(&mut self) { + self.len = 0; + } + /// The next entity's state, reset (`EntityState::reset`). + pub fn add(&mut self, id: i32, uuid: u128, kind: &str) -> &mut EntityState { + if self.len == self.list.len() { + self.list.push(EntityState::default()); + } + let e = &mut self.list[self.len]; + self.len += 1; + e.reset(id, uuid, kind); + e + } + pub fn push(&mut self, e: EntityState) { + match self.list.get_mut(self.len) { + Some(slot) => *slot = e, + None => self.list.push(e), + } + self.len += 1; + } + pub fn as_slice(&self) -> &[EntityState] { + &self.list[..self.len] + } +} + +impl Extend for States { + fn extend>(&mut self, states: I) { + for e in states { + self.push(e); + } + } +} + /// `AbstractArrow`: every update sends its position and rotation. fn is_arrow(kind: &str) -> bool { matches!( @@ -792,6 +858,31 @@ mod tests { } } + #[test] + fn states_are_written_over_last_ticks_in_place() { + let mut states = States::default(); + let e = states.add(1, 1, "minecraft:zombie"); + e.position = [1.0, 2.0, 3.0]; + e.meta.push((9, serializer::FLOAT, Value::Float(20.0))); + e.equipment.push((0, None)); + let (kind, meta) = (e.kind.as_ptr(), e.meta.as_ptr()); + states.push(state(2.0)); + states.clear(); + let e = states.add(3, 3, "minecraft:husk"); + // Nothing of the last tick's state is left, but its allocations are. + let fresh = EntityState { + id: 3, + uuid: 3, + kind: "minecraft:husk".into(), + ..EntityState::default() + }; + assert_eq!(*e, fresh); + assert_eq!((e.kind.as_ptr(), e.meta.as_ptr()), (kind, meta)); + assert_eq!(states.as_slice(), std::slice::from_ref(&fresh)); + states.extend([state(5.0), state(6.0)]); + assert_eq!(states.as_slice(), [fresh, state(5.0), state(6.0)]); + } + /// The entity as added this tick, then its updates (one a tick). fn added(e: &EntityState) -> ServerEntity { ServerEntity::new(e, Some(100), &Riding::default()) diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 4cf0c5c48..a71418f18 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -80,6 +80,7 @@ pub mod graphics; pub mod graphics_style; pub mod graphics_ui; pub mod health; +pub mod heartbeat; pub mod hotbar; pub mod hud; pub mod hud_layout; diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 28bcadeed..e87032f07 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -690,6 +690,8 @@ pub struct Extra { pub blocking: bool, pub fall_flying: bool, pub fall_fly_ticks: i32, + /// `LivingEntity.isAutoSpinAttack` (Riptide). + pub auto_spin_attack: bool, pub main_hand_left: bool, pub elytra: ElytraAnimation, pub swim: SwimAnimation, diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 99548ec3f..4248f22c6 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -771,6 +771,11 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 .sqrt() .min(1.0); base *= glam::Mat4::from_rotation_z(f * flip_degrees(m).to_radians()); + } else if m.extra.auto_spin_attack { + // Riptide: lying along the look direction, spinning 75 degrees a tick. + let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha; + base *= glam::Mat4::from_rotation_x((-90.0 - pitch).to_radians()) + * glam::Mat4::from_rotation_y(((m.age as f32 + alpha) * -75.0).to_radians()); } else if matches!(m.custom_name.as_deref(), Some("Dinnerbone" | "Grumm")) { // isEntityUpsideDown base *= glam::Mat4::from_translation(glam::Vec3::new(0.0, m.height() + 0.1, 0.0)) @@ -781,7 +786,9 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 let ticks = m.extra.fall_fly_ticks as f32 + alpha; let scale = (ticks * ticks / 100.0).min(1.0); let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha; - base *= glam::Mat4::from_rotation_x((scale * (-90.0 - pitch)).to_radians()); + if !m.extra.auto_spin_attack { + base *= glam::Mat4::from_rotation_x((scale * (-90.0 - pitch)).to_radians()); + } let yaw = m.previous_head_yaw + wrap(m.head_yaw - m.previous_head_yaw) * alpha; let (look_x, look_z) = (-(yaw.to_radians()).msin(), (yaw.to_radians()).mcos()); let v = m.velocity; diff --git a/crates/horizon-client/src/mobs/spawn_selection.rs b/crates/horizon-client/src/mobs/spawn_selection.rs index c87c3d97a..614a2b2b5 100644 --- a/crates/horizon-client/src/mobs/spawn_selection.rs +++ b/crates/horizon-client/src/mobs/spawn_selection.rs @@ -691,33 +691,37 @@ pub struct StructureAt { } /// `NetherFortressStructure.FORTRESS_ENEMIES`. -pub fn fortress_enemies() -> Vec { - let e = |entity: &str, weight, min, max, constant| SpawnEntry { - entity: entity.to_owned(), - weight, - min, - max, - constant, - }; - vec![ - e("minecraft:blaze", 10, 2, 3, false), - e("minecraft:zombified_piglin", 5, 4, 4, true), - e("minecraft:wither_skeleton", 8, 5, 5, true), - e("minecraft:skeleton", 2, 5, 5, true), - e("minecraft:magma_cube", 3, 4, 4, true), - ] +pub fn fortress_enemies() -> &'static [SpawnEntry] { + static ENEMIES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + let e = |entity: &str, weight, min, max, constant| SpawnEntry { + entity: entity.to_owned(), + weight, + min, + max, + constant, + }; + vec![ + e("minecraft:blaze", 10, 2, 3, false), + e("minecraft:zombified_piglin", 5, 4, 4, true), + e("minecraft:wither_skeleton", 8, 5, 5, true), + e("minecraft:skeleton", 2, 5, 5, true), + e("minecraft:magma_cube", 3, 4, 4, true), + ] + }); + &ENEMIES } /// `NaturalSpawner.mobsAt`: the fortress bounds on nether bricks, then /// `ChunkGenerator.getMobsAt` (the first referenced structure whose override -/// for the category holds the position), else the biome's list. +/// for the category holds the position), else the biome's list. Borrowed from +/// the level: a spawn attempt looks it up up to twice. pub fn mobs_at( l: &L, category: Category, x: i32, y: i32, z: i32, -) -> Vec { +) -> &[SpawnEntry] { // Only a fortress or a structure overriding this category's spawns can // change the list, so only theirs are looked up (the references reaching // most chunks are mineshafts, which override nothing). @@ -738,13 +742,12 @@ pub fn mobs_at( if let Some(o) = overrides.get(&s.id, category.name()) && (if o.piece { s.in_piece } else { s.in_bounds }) { - return o.spawns.clone(); + return &o.spawns; } } l.biome_spawns(x, y, z) .and_then(|b| b.get(category.name())) - .cloned() - .unwrap_or_default() + .map_or(&[], Vec::as_slice) } /// `WeightedList.getRandom`. @@ -1103,7 +1106,7 @@ pub fn spawn_category_for_position( { None } else { - weighted_pick(&mobs_at(l, category, x, y, z), random) + weighted_pick(mobs_at(l, category, x, y, z), random) }; let Some(entry) = list else { break; diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 7bdaea785..b1088188c 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -247,6 +247,14 @@ pub struct PlayerMotion { pub glide_ending: bool, /// Glider durability to take this tick (updateFallFlying). pub glider_wear: u32, + /// `LivingEntity.autoSpinAttackTicks` and `autoSpinAttackDmg`: Riptide's + /// spin. The damage stays until the game has dealt the hit. + pub auto_spin_attack_ticks: i32, + pub auto_spin_attack_dmg: f32, + /// The living entities the game put near a spinning player (id, box). + pub spin_targets: Vec<(u32, Aabb)>, + /// The entity the spin touched this tick (`doAutoAttackOnTouch`). + pub spin_hit: Option, pub sprinting: bool, pub sprint_input: crate::sprint::SprintState, pub shift_key_down: bool, @@ -262,6 +270,10 @@ pub struct PlayerMotion { pub steps: MovementDistances, pub needs_sync: bool, pub sync_position: bool, + /// Moved by an ender pearl (`ServerPlayer.teleport`): a server sends the + /// position packet even when the player lands where they stood. The + /// server takes it at the end of a guest's turn. + pub teleported: bool, pub events: Vec, pub attributes: LiveAttributes i32>>, pub impulse: ImpulseContext, @@ -409,6 +421,10 @@ impl PlayerMotion { fall_fly_ticks: 0, glide_ending: false, glider_wear: 0, + auto_spin_attack_ticks: 0, + auto_spin_attack_dmg: 0.0, + spin_targets: Vec::new(), + spin_hit: None, sprinting: false, sprint_input: crate::sprint::SprintState::default(), shift_key_down: false, @@ -428,6 +444,7 @@ impl PlayerMotion { }, needs_sync: false, sync_position: false, + teleported: false, events: Vec::new(), attributes, fluids: PlayerFluids::default(), @@ -919,6 +936,7 @@ impl MotionWorld<'_, '_, '_> { input.z = 0.0; } self.jump_phase()?; + let before_travel = self.player.bounds(); // Player.travel: swim steering, then LivingEntity.travel (fluids // first, then gliding, then air), keeping 0.6 of the vertical speed // while flying. @@ -973,8 +991,26 @@ impl MotionWorld<'_, '_, '_> { { self.freeze_damage()?; } + if self.player.auto_spin_attack_ticks > 0 { + self.player.auto_spin_attack_ticks -= 1; + self.check_auto_spin_attack(before_travel); + } Ok(()) } + /// `LivingEntity.checkAutoSpinAttack`: the first living entity in the + /// box swept this tick takes the hit and ends the spin, bouncing the + /// player back at a fifth of its speed; a wall ends it too. + fn check_auto_spin_attack(&mut self, before: Aabb) { + let swept = before.union_bounds(self.player.bounds()); + let p = &mut self.player; + if let Some(&(id, _)) = p.spin_targets.iter().find(|(_, b)| b.intersects(swept)) { + p.spin_hit = Some(id); + p.auto_spin_attack_ticks = 0; + p.velocity = p.velocity.scale(-0.2); + } else if p.collision.horizontal { + p.auto_spin_attack_ticks = 0; + } + } /// `hurtServer(damageSources().freeze(), 1)` for a fully frozen player. fn freeze_damage(&mut self) -> Result<()> { let id = self @@ -2659,6 +2695,49 @@ mod tests { } } #[test] + fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let mut scene = World::test_scene(&assets, &render, 2).unwrap(); + let ahead = Aabb::new(Vec3::new(20.2, 9.0, 21.5), Vec3::new(20.8, 11.0, 22.1)); + for target in [None, Some(ahead)] { + let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap(); + player.auto_spin_attack_ticks = 20; + player.auto_spin_attack_dmg = 8.0; + player.velocity = Vec3::new(0.0, 0.0, 1.0); + player.spin_targets = target.map(|b| (7, b)).into_iter().collect(); + let mut w = MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + }; + w.simulation_tick([0.0, 0.0], false).unwrap(); + if target.is_some() { + // Touched: the hit, no more spin, bounced back at a fifth. + assert_eq!(w.player.spin_hit, Some(7)); + assert_eq!(w.player.auto_spin_attack_ticks, 0); + assert!(w.player.velocity.z < 0.0, "{:?}", w.player.velocity); + assert_eq!(w.player.pose, crate::pose::Pose::Standing); + } else { + assert_eq!(w.player.spin_hit, None); + assert_eq!(w.player.auto_spin_attack_ticks, 19); + assert_eq!(w.player.pose, crate::pose::Pose::SpinAttack); + assert_eq!(w.player.dimensions.height, 0.6); + } + } + } + #[test] fn a_landing_glide_lasts_a_tick_so_a_jump_keeps_its_speed() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../.local/horizon-client") diff --git a/crates/horizon-client/src/pose.rs b/crates/horizon-client/src/pose.rs index 9c8124b0e..e34b1f1fc 100644 --- a/crates/horizon-client/src/pose.rs +++ b/crates/horizon-client/src/pose.rs @@ -14,6 +14,7 @@ pub enum Pose { Crouching, Swimming, FallFlying, + SpinAttack, } impl Pose { pub fn name(self) -> &'static str { @@ -22,13 +23,14 @@ impl Pose { Self::Crouching => "CROUCHING", Self::Swimming => "SWIMMING", Self::FallFlying => "FALL_FLYING", + Self::SpinAttack => "SPIN_ATTACK", } } pub fn dimensions(self, scale: f32) -> Dimensions { let (height, eye_height) = match self { Self::Standing => (1.8, 1.62), Self::Crouching => (1.5, 1.27), - Self::Swimming | Self::FallFlying => (0.6, 0.4), + Self::Swimming | Self::FallFlying | Self::SpinAttack => (0.6, 0.4), }; Dimensions { width: 0.6 * scale, @@ -98,11 +100,13 @@ impl MotionWorld<'_, '_, '_> { if !self.can_fit_pose(Pose::Swimming)? { return Ok(()); } - // Player.getDesiredPose: swimming before gliding. + // Player.getDesiredPose: swimming, gliding, Riptide's spin. let desired = if self.player.swimming { Pose::Swimming } else if self.player.fall_flying { Pose::FallFlying + } else if self.player.auto_spin_attack_ticks > 0 { + Pose::SpinAttack } else if self.player.shift_key_down && !self.player.flying { Pose::Crouching } else { diff --git a/crates/horizon-client/src/spans.rs b/crates/horizon-client/src/spans.rs index 5c37f2bfc..ce69be8ce 100644 --- a/crates/horizon-client/src/spans.rs +++ b/crates/horizon-client/src/spans.rs @@ -1,8 +1,10 @@ //! Named timings within a frame, for finding what makes a frame slow: //! `HORIZON_SPIKES=MS` prints the spans of every frame whose CPU time //! exceeds MS milliseconds, and of every server tick (dedicated or -//! integrated) longer than MS. Costs one clock read per span when enabled -//! and a relaxed load otherwise. +//! integrated) longer than MS. Each span keeps its wall time and the +//! thread's CPU time inside it, so a span that waited (for a lock, the GPU +//! or a core on a busy machine) shows apart from one that worked. Costs two +//! clock reads per span when enabled and a relaxed load otherwise. use std::{ cell::RefCell, sync::atomic::{AtomicBool, Ordering}, @@ -11,7 +13,15 @@ use std::{ static ENABLED: AtomicBool = AtomicBool::new(false); thread_local! { - static SPANS: RefCell> = const { RefCell::new(Vec::new()) }; + static SPANS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// A named timing: wall milliseconds and the thread's CPU milliseconds in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Span { + pub name: &'static str, + pub ms: f32, + pub cpu_ms: f32, } pub fn enable(on: bool) { @@ -23,10 +33,13 @@ pub fn time(name: &'static str, f: impl FnOnce() -> T) -> T { if !ENABLED.load(Ordering::Relaxed) { return f(); } - let started = Instant::now(); + let (started, cpu) = (Instant::now(), thread_cpu()); let out = f(); let ms = started.elapsed().as_secs_f32() * 1000.0; - SPANS.with(|s| s.borrow_mut().push((name, ms))); + let cpu_ms = thread_cpu() + .zip(cpu) + .map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0); + SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms })); out } /// The CPU time this thread has used, where the platform tells it: a tick @@ -42,21 +55,84 @@ pub fn thread_cpu() -> Option { None } /// The frame's spans, summed by name, slowest first; clears them. -pub fn take() -> Vec<(&'static str, f32)> { +pub fn take() -> Vec { let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut())); - let mut sums: Vec<(&'static str, f32)> = Vec::new(); - for (name, ms) in spans { - match sums.iter_mut().find(|(n, _)| *n == name) { - Some(entry) => entry.1 += ms, - None => sums.push((name, ms)), + let mut sums: Vec = Vec::new(); + for span in spans { + match sums.iter_mut().find(|s| s.name == span.name) { + Some(sum) => { + sum.ms += span.ms; + sum.cpu_ms += span.cpu_ms; + } + None => sums.push(span), } } - sums.sort_by(|a, b| b.1.total_cmp(&a.1)); + sums.sort_by(|a, b| b.ms.total_cmp(&a.ms)); sums } +/// `name wall` for spans of at least 0.5 ms, with `/cpu` when the thread +/// worked for less than nine tenths of it: the slow-frame and slow-tick lines. +pub fn describe(spans: &[Span]) -> String { + let list: Vec = spans + .iter() + .filter(|s| s.ms >= 0.5) + .map(|s| { + if s.cpu_ms < s.ms * 0.9 { + format!("{} {:.1}/{:.1}", s.name, s.ms, s.cpu_ms) + } else { + format!("{} {:.1}", s.name, s.ms) + } + }) + .collect(); + list.join(", ") +} #[cfg(test)] mod tests { + use super::Span; + + /// Spans of one name add up; a span the thread spent mostly waiting + /// shows its CPU time after the wall time, short ones are left out. + #[test] + fn spans_sum_by_name_and_show_waits() { + super::SPANS.with(|s| { + s.borrow_mut().extend([ + Span { + name: "tick", + ms: 2.0, + cpu_ms: 2.0, + }, + Span { + name: "submit", + ms: 30.0, + cpu_ms: 0.2, + }, + Span { + name: "tick", + ms: 3.0, + cpu_ms: 2.9, + }, + Span { + name: "hud", + ms: 0.3, + cpu_ms: 0.3, + }, + ]); + }); + let spans = super::take(); + assert_eq!( + spans[0], + Span { + name: "submit", + ms: 30.0, + cpu_ms: 0.2 + } + ); + assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0)); + assert_eq!(super::describe(&spans), "submit 30.0/0.2, tick 5.0"); + assert!(super::take().is_empty()); + } + /// The thread's CPU clock moves with work done on it, not with time /// spent asleep. #[cfg(unix)] diff --git a/crates/horizon-client/src/telemetry.rs b/crates/horizon-client/src/telemetry.rs index 2010df04a..ec67fe7f7 100644 --- a/crates/horizon-client/src/telemetry.rs +++ b/crates/horizon-client/src/telemetry.rs @@ -1,6 +1,7 @@ //! The game's part in opt-in data collection (`horizon_telemetry`, docs/telemetry.md). When the //! player allows it, the game keeps a scrubbed crash report, or a play session's performance -//! summary, in the outbox. It never sends anything itself: the launcher does, at its next start. +//! summary, in the outbox. It never sends those itself: the launcher does, at its next start (the +//! game's only sending is the anonymous "I'm playing" ping, `heartbeat.rs`). //! Without the player's yes for that kind nothing is kept, and crash-reports/ stays local. use std::path::Path; use std::sync::atomic::{AtomicU32, Ordering}; diff --git a/crates/horizon-port-pack/src/carry.rs b/crates/horizon-port-pack/src/carry.rs index dd6b148f3..e6056e519 100644 --- a/crates/horizon-port-pack/src/carry.rs +++ b/crates/horizon-port-pack/src/carry.rs @@ -707,7 +707,8 @@ pub fn carry( let mut biome_conventions = BTreeSet::new(); let set_tags = crate::worldgen::structure_set_tags(&mut zip); let piece_counts = crate::worldgen::piece_counts(&mut zip); - let processor_data = crate::processors::ProcessorData::read(&mut zip); + let mut processor_data = crate::processors::ProcessorData::read(&mut zip); + processor_data.mark_loaded(loaded); report.namespaces = extra_namespaces(&mut zip, id, pack.mods); let owned: Vec<&str> = std::iter::once(id.as_str()) .chain(report.namespaces.iter().map(String::as_str)) diff --git a/crates/horizon-port-pack/src/processors.rs b/crates/horizon-port-pack/src/processors.rs index 7f26282bf..e6c0fe7d8 100644 --- a/crates/horizon-port-pack/src/processors.rs +++ b/crates/horizon-port-pack/src/processors.rs @@ -16,6 +16,12 @@ //! `condition` become the game's `horizon:apply_random` and //! `horizon:condition`, with the lists inside them carried the same way, //! and its `set_block` (simple or weighted states) `horizon:set_block`. +//! - Repurposed Structures' mineshaft sky view becomes the game's +//! `horizon:mineshaft_sky_view`. +//! - Choice Theorem's Overhauled Village's `modular_compat` runs another +//! list only when the mod it names is loaded: that list is written in its +//! place when the mod is ported along, and it is taken out (a no-op, as in +//! the mod) when it is not. //! //! What is not here is taken out of its list later and counted //! (`worldgen::upgrade`). The entity NBT is written in 26.3's shape (an @@ -37,9 +43,37 @@ pub struct ProcessorData { pub replace_vanilla: BTreeSet<(String, String)>, /// The mod's processor lists by id, for Lithostitched's references. pub lists: BTreeMap, + /// The mods named by its `ctov:modular_compat` processors that are + /// ported along ([`Self::mark_loaded`]). + pub compat_loaded: BTreeSet, } +/// Choice Theorem's Overhauled Village's processor that runs the list +/// `reference` when mod `modid` is loaded (ModularCompatProcessor). +const MODULAR_COMPAT: &str = "ctov:modular_compat"; + impl ProcessorData { + /// Notes which mods its compat processors name are `loaded`. + pub fn mark_loaded(&mut self, loaded: &dyn Fn(&str) -> bool) { + fn walk(v: &Value, out: &mut BTreeSet) { + match v { + Value::Object(o) => { + if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT) { + if let Some(id) = o.get("modid").and_then(Value::as_str) { + out.insert(id.to_owned()); + } + } + o.values().for_each(|v| walk(v, out)); + } + Value::Array(a) => a.iter().for_each(|v| walk(v, out)), + _ => {} + } + } + let mut named = BTreeSet::new(); + self.lists.values().for_each(|l| walk(l, &mut named)); + self.compat_loaded = named.into_iter().filter(|m| loaded(m)).collect(); + } + pub fn read(zip: &mut zip::ZipArchive) -> Self { let mut out = Self::default(); for i in 0..zip.len() { @@ -408,6 +442,17 @@ fn references( let mut out = Vec::with_capacity(list.len()); let mut changed = false; for p in list { + if p["processor_type"] == MODULAR_COMPAT && depth < 8 { + *carried.entry(MODULAR_COMPAT.into()).or_default() += 1; + changed = true; + let loaded = p["modid"] + .as_str() + .is_some_and(|m| data.compat_loaded.contains(m)); + if let Some(inner) = loaded.then(|| referenced(&p["reference"], data)).flatten() { + out.extend(references(inner, data, carried, depth + 1).0); + } + continue; + } let named = (p["processor_type"] == "lithostitched:reference" && depth < 8) .then(|| referenced(&p["processor_lists"], data)) .flatten(); @@ -491,6 +536,10 @@ fn as_data(kind: &str, p: &Value, data: &ProcessorData) -> Option> { "repurposed_structures:waterlogging_when_replacing_water_processor" => { vec![json!({"processor_type": "horizon:waterlog_in_world_water"})] } + // Its mineshafts: nothing above the ocean floor but grounded rails. + "repurposed_structures:mineshaft_sky_view_processor" => { + vec![json!({"processor_type": "horizon:mineshaft_sky_view"})] + } "moogs_structures:vanilla_loot_swap_processor" => { let key = ( p["modid"].as_str()?.to_owned(), @@ -1344,7 +1393,8 @@ mod tests { #[test] fn repurposed_waterlogging_follows_the_world() { let mut list = json!({"processors": [{"processor_type": - "repurposed_structures:waterlogging_when_replacing_water_processor"}]}); + "repurposed_structures:waterlogging_when_replacing_water_processor"}, + {"processor_type": "repurposed_structures:mineshaft_sky_view_processor"}]}); let mut carried = BTreeMap::new(); assert!(carry( "processor_list", @@ -1354,7 +1404,8 @@ mod tests { )); assert_eq!( list["processors"], - json!([{"processor_type": "horizon:waterlog_in_world_water"}]) + json!([{"processor_type": "horizon:waterlog_in_world_water"}, + {"processor_type": "horizon:mineshaft_sky_view"}]) ); } @@ -1391,6 +1442,27 @@ mod tests { assert_eq!(p[1]["processor_type"], "lithostitched:block_swap"); } + #[test] + fn modular_compat_runs_its_list_only_with_its_mod() { + let mut data = ProcessorData::default(); + let swap = json!({"processor_type": "minecraft:rule", "rules": []}); + data.lists + .insert("v:house_ns".into(), json!({"processors": [swap.clone()]})); + let compat = |m: &str| json!({"processor_type": "ctov:modular_compat", "modid": m, "reference": "v:house_ns"}); + let gravity = json!({"processor_type": "minecraft:gravity"}); + data.lists.insert( + "v:house".into(), + json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]}), + ); + data.mark_loaded(&|m| m == "ns"); + assert_eq!(data.compat_loaded, BTreeSet::from(["ns".to_owned()])); + let mut v = json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]}); + let mut carried = BTreeMap::new(); + assert!(carry("processor_list", &mut v, &data, &mut carried)); + assert_eq!(v, json!({"processors": [swap, gravity]})); + assert_eq!(carried["ctov:modular_compat"], 2); + } + #[test] fn lithostitched_references_are_written_in() { let mut data = data(); diff --git a/crates/horizon-port-pack/src/worldgen.rs b/crates/horizon-port-pack/src/worldgen.rs index 918dbf05e..91b659217 100644 --- a/crates/horizon-port-pack/src/worldgen.rs +++ b/crates/horizon-port-pack/src/worldgen.rs @@ -86,6 +86,31 @@ const YUNG_REPLACES: [(&str, &str); 7] = [ ("betterfortresses:fortress", "minecraft:fortress"), ("bettermineshafts:mineshaft_", "minecraft:mineshaft"), ]; +/// Mo' Structures' placement (`mostructures:type`) takes its spacing and +/// separation from the mod's config, not its files: the defaults it writes +/// (MoStructuresConfig), by config key, as (spacing, separation). +const MO_SPREADS: [(&str, i64, i64); 17] = [ + ("abandoned_church", 33, 20), + ("air_balloon", 22, 12), + ("barn_house", 32, 16), + ("big_pyramid", 32, 25), + ("ice_tower", 28, 20), + ("jungle_pyramid", 25, 22), + ("killer_bunny_castle", 32, 22), + ("lighthouse", 24, 14), + ("moai", 17, 15), + ("pillager_factory", 34, 16), + ("pillager_mines", 40, 27), + ("pirate_ship", 32, 16), + ("tavern", 26, 16), + ("the_castle_in_the_sky", 32, 22), + ("villager_market", 32, 16), + ("villager_tower", 26, 16), + ("sphinx", 24, 16), +]; +/// Mo' Structures keeps a start out of this many chunks around a start of +/// each set in its `structure_set_to_avoid`. +const MO_AVOID_CHUNKS: i64 = 3; /// Moog's Structure Lib spreads its structures 1.65 times as far apart as /// its files say (AdvancedRandomSpread's constructor). const MOOGS_SPACING: f64 = 1.65; @@ -510,12 +535,44 @@ fn upgrade_types( v["type"] = "minecraft:jigsaw".into(); true } + // Mo' Structures' ModStructure: vanilla's jigsaw that starts only + // where the terrain under the 5×5 chunks around varies by no + // more than `heightRange` blocks (-1: anywhere). + Some("mostructures:generic") => { + let range = v + .as_object_mut() + .and_then(|o| o.remove("heightRange")) + .and_then(|r| r.as_i64()) + .unwrap_or(-1); + if range >= 0 { + v["horizon:start_checks"] = + serde_json::json!({"terrain_radius": 2, "terrain_range": range}); + } + v["type"] = "minecraft:jigsaw".into(); + true + } // Formations' SimpleStructure: its pool and placement, laid out // by the game (`horizon_world::worldgen::structure::formations`). Some("formations:simple") => { v["type"] = "horizon:formations".into(); true } + // Its PiecedStructure: the same start, then vanilla's jigsaw + // (`horizon:pieced`). + Some("formations:pieced") => { + let Some(o) = v.as_object_mut() else { + return false; + }; + let pool = o.remove("start_pool").unwrap_or_default(); + let mut pieced = serde_json::Map::new(); + for k in ["max_depth", "max_distance_from_center"] { + pieced.insert(k.into(), o.remove(k).unwrap_or_default()); + } + o.insert("template".into(), pool); + o.insert("horizon:pieced".into(), pieced.into()); + o.insert("type".into(), "horizon:formations".into()); + true + } Some(t) if !t.starts_with("minecraft:") => { unsupported(t); false @@ -533,6 +590,7 @@ fn upgrade_types( p["type"] = "minecraft:random_spread".into(); true } + Some("mostructures:type") => mo_placement(&mut v["placement"], lost), Some(t) if YUNG_SPREADS.contains(&t) => { let t = t.to_owned(); yung_placement(&t, &mut v["placement"], set_tags, lost); @@ -1022,6 +1080,41 @@ fn placement_rules( } } +/// Mo' Structures' placement as vanilla's random spread: the spacing and +/// separation of its config key, and its sets to avoid as +/// `horizon:exclusion_zone` tested by their spread alone +/// (ModStructurePlacement.exclusionZoneCheck). A key the config does not +/// have fails the mod's codec, and the set stays as it is. +fn mo_placement(p: &mut Value, lost: &mut BTreeMap) -> bool { + let key = p["config_key"].as_str().unwrap_or_default(); + let Some(&(_, spacing, separation)) = MO_SPREADS.iter().find(|(k, ..)| *k == key) else { + *lost.entry("config_key".into()).or_default() += 1; + return false; + }; + let Some(o) = p.as_object_mut() else { + return false; + }; + o.remove("config_key"); + o.insert("spacing".into(), spacing.into()); + o.insert("separation".into(), separation.into()); + let avoid = o.remove("structure_set_to_avoid"); + let sets: Vec = avoid + .iter() + .flat_map(|a| a.as_array().into_iter().flatten()) + .filter(|s| s.is_string()) + .cloned() + .collect(); + if !sets.is_empty() { + o.insert( + "horizon:exclusion_zone".into(), + serde_json::json!({"other_sets": sets, "chunk_count": MO_AVOID_CHUNKS, + "their_zones": false}), + ); + } + o.insert("type".into(), "minecraft:random_spread".into()); + true +} + /// One of YUNG's placements as vanilla's random spread with `horizon:` keys: /// the enhanced exclusion zone as `horizon:exclusion_zone`; the temples' /// ocean and river search (findBiomeHorizontal around the chunk's middle @@ -1222,6 +1315,21 @@ mod tests { const NO_TAGS: BTreeMap> = BTreeMap::new(); + #[test] + fn formations_pieced_structure_keeps_its_jigsaw() { + let mut v = json!({"type": "formations:pieced", "start_pool": "fo:fountain/fountains", + "max_depth": 2, "max_distance_from_center": 116, "placement": "surface"}); + let mut report = Upgrade::default(); + assert!(upgrade("structure", &mut v, &mut report, &BTreeMap::new())); + assert_eq!( + v, + json!({"type": "horizon:formations", "template": "fo:fountain/fountains", + "placement": "surface", + "horizon:pieced": {"max_depth": 2, "max_distance_from_center": 116}}) + ); + assert!(report.unsupported.is_empty()); + } + #[test] fn formations_become_the_games() { let mut report = Upgrade::default(); @@ -1247,12 +1355,7 @@ mod tests { (json!(6), json!(1)) ); assert!(e(0).get("ground_level").is_none()); - // The pieced structure stays out. - let mut pieced = json!({"type": "formations:pieced", "start_pool": "f:p", "max_depth": 2, - "max_distance_from_center": 40, "placement": "surface", "biomes": "#f:x"}); - assert!(!upgrade("structure", &mut pieced, &mut report, &NO_TAGS)); - assert_eq!(report.unsupported.get("formations:pieced"), Some(&1)); - assert_eq!(report.unsupported.len(), 1); + assert!(report.unsupported.is_empty()); } #[test] @@ -1385,6 +1488,71 @@ mod tests { ); } + #[test] + fn mo_structures_become_vanillas_jigsaw_and_spread() { + let mut report = Upgrade::default(); + let mut s = json!({"type": "mostructures:generic", "start_pool": "mostructures:a/base", + "size": 7, "heightRange": 25, "use_expansion_hack": true, + "start_height": {"absolute": 0}, "max_distance_from_center": 80}); + assert!(upgrade("structure", &mut s, &mut report, &BTreeMap::new())); + assert_eq!(s["type"], "minecraft:jigsaw"); + assert!(s.get("heightRange").is_none()); + assert_eq!( + s["horizon:start_checks"], + json!({"terrain_radius": 2, "terrain_range": 25}) + ); + let mut anywhere = json!({"type": "mostructures:generic", "heightRange": -1}); + assert!(upgrade( + "structure", + &mut anywhere, + &mut report, + &BTreeMap::new() + )); + assert!(anywhere.get("horizon:start_checks").is_none()); + + // The config's spacing and separation, not the file's. + let mut set = json!({"placement": {"type": "mostructures:type", "config_key": "moai", + "salt": 12994829, "spacing": 17, "separation": 15, + "structure_set_to_avoid": ["minecraft:villages", "mostructures:lighthouse"]}}); + assert!(upgrade( + "structure_set", + &mut set, + &mut report, + &BTreeMap::new() + )); + assert_eq!( + set["placement"], + json!({"type": "minecraft:random_spread", "salt": 12994829, "spacing": 17, + "separation": 15, "horizon:exclusion_zone": {"chunk_count": 3, + "other_sets": ["minecraft:villages", "mostructures:lighthouse"], + "their_zones": false}}) + ); + let mut set = json!({"placement": {"type": "mostructures:type", + "config_key": "jungle_pyramid", "salt": 1, "spacing": 16, "separation": 14}}); + assert!(upgrade( + "structure_set", + &mut set, + &mut report, + &BTreeMap::new() + )); + assert_eq!( + ( + &set["placement"]["spacing"], + &set["placement"]["separation"] + ), + (&json!(25), &json!(22)) + ); + assert!(set["placement"].get("horizon:exclusion_zone").is_none()); + let mut unknown = json!({"placement": {"type": "mostructures:type", "config_key": "x"}}); + assert!(!upgrade( + "structure_set", + &mut unknown, + &mut report, + &BTreeMap::new() + )); + assert_eq!(report.rules_lost["config_key"], 1); + } + #[test] fn super_exclusion_zones_name_their_sets() { let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); diff --git a/crates/horizon-telemetry-server/README.md b/crates/horizon-telemetry-server/README.md index cf2d52393..dc1f51248 100644 --- a/crates/horizon-telemetry-server/README.md +++ b/crates/horizon-telemetry-server/README.md @@ -1,6 +1,6 @@ # horizon-telemetry-server -This server receives what players choose to send (performance summaries, crash and bug reports, benchmark uploads: [docs/telemetry.md](../../docs/telemetry.md)) and stores it in one SQLite file. It serves anonymous totals to the website. It is one small Rust process, about 7 MB of memory when idle. +This server receives what players choose to send (performance summaries, crash and bug reports, benchmark uploads: [docs/telemetry.md](../../docs/telemetry.md)) and stores it in one SQLite file. It serves anonymous totals to the website. It also keeps the live player count from the games' anonymous "I'm playing" pings, in memory only. It is one small Rust process, about 7 MB of memory when idle. cargo run --release # in this folder: its own workspace, like the launcher's PORT=8080 HORIZON_TELEMETRY_DB=/data/telemetry.sqlite3 horizon-telemetry-server @@ -14,6 +14,8 @@ The launcher sends to it when `HORIZON_TELEMETRY_URL` is set (and to `DEFAULT_EN | `POST /v1/batch` | A zstd batch (`Content-Encoding: zstd`, at most 256 KiB, 1 MiB inflated, 64 items). Returns 204. Returns 400 for a bad batch, 413 for one too large, 415 if it is not zstd, 429 when over a limit. | | `DELETE /v1/installs/` | Deletes everything that install sent. Its share of the daily totals is rebuilt at once. | | `GET /v1/aggregate?days=30` | The totals the website reads. Cached 10 minutes, with `Access-Control-Allow-Origin` set to the website. | +| `POST /v1/live` | A game's "I'm playing" ping (JSON, at most 512 bytes). Returns 204. Returns 400 for a bad ping, 413 for one too large, 429 over the address's limit, 503 when it holds `LIVE_MAX_SESSIONS` already. | +| `GET /v1/live` | How many play now, rounded: `playing`, `lan`, `versions`, `platforms`. Made again at most every 15 seconds, with the same `Access-Control-Allow-Origin`. | | `GET /healthz` | `ok` | The totals are grouped per version, OS, GPU vendor and render distance. Each group has its sessions, install-days, hours played, the 14 frame-time buckets summed, and the mean p50 and p99 frame times. Groups with fewer than 5 install-days are left out. The totals also list crash counts per version and the newest 50 benchmark uploads. @@ -27,6 +29,8 @@ The totals are grouped per version, OS, GPU vendor and render distance. Each gro Measured: **about 190 bytes per play session** on disk, indexes included (`tests/server.rs`, 10 000 sessions). 1 000 players playing twice a day for 180 days is about 70 MB. +The live count is a table in memory: a session ID, when it last pinged, its version, platform and LAN flag, about 100 bytes a session. A session counts while its last ping is under 2 minutes old and is forgotten after that. Nothing of it reaches the disk, so a restart empties it; it is whole again a minute later. + The day stored is the day the server received an item. The sender's IP address is never written down. The per-address rate limit keeps addresses in memory for an hour at most. Raw rows are deleted after `HORIZON_TELEMETRY_RETENTION_DAYS` (180). The daily totals stay; they hold no install ID. @@ -43,6 +47,9 @@ All are environment variables: - `HORIZON_TELEMETRY_ITEMS_PER_DAY` per install (100) - `HORIZON_TELEMETRY_TRUST_PROXY=1` behind a proxy (the client's address is then the first of `X-Forwarded-For`) - `HORIZON_TELEMETRY_ALLOW_ORIGIN` (`https://playhorizon.dev`) +- `HORIZON_TELEMETRY_LIVE_PER_HOUR` per address, pings and reads (600, bursts of 60: ten players behind one address) +- `HORIZON_TELEMETRY_LIVE_WINDOW_SECONDS` (120) +- `HORIZON_TELEMETRY_LIVE_MAX_SESSIONS` (200 000) ## Hosting diff --git a/crates/horizon-telemetry-server/src/lib.rs b/crates/horizon-telemetry-server/src/lib.rs index b1521a714..061a9ca37 100644 --- a/crates/horizon-telemetry-server/src/lib.rs +++ b/crates/horizon-telemetry-server/src/lib.rs @@ -3,12 +3,15 @@ //! - `POST /v1/batch`: a zstd batch from the launcher, checked by `horizon_telemetry::batch`. //! - `DELETE /v1/installs/`: everything an install sent goes. //! - `GET /v1/aggregate?days=30`: the anonymous totals the website reads, cached. +//! - `POST /v1/live`: a game's anonymous "I'm playing" ping (`live`, in memory only). +//! - `GET /v1/live`: how many play now, rounded, cached briefly. //! - `GET /healthz`. //! //! Abuse limits: a token bucket per IP address (kept in memory for an hour, never stored), a -//! daily item cap per install, and the batch size limits. Raw rows expire after -//! `retention_days`; the daily totals stay. One small process and one SQLite file: see -//! `README.md` for running it. +//! daily item cap per install, and the batch size limits. The live count has its own, larger +//! bucket per address (several players can share one) and a cap on the sessions it holds. Raw +//! rows expire after `retention_days`; the daily totals stay. One small process and one SQLite +//! file: see `README.md` for running it. use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; use std::sync::{Arc, Mutex}; @@ -21,8 +24,11 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post}; use horizon_telemetry::batch; +pub mod live; pub mod store; +use horizon_telemetry::live::{MAX_PING_BYTES, Ping}; +use live::Live; use store::Store; pub struct Config { @@ -38,6 +44,13 @@ pub struct Config { pub trust_proxy: bool, /// The website allowed to read the totals from a browser. pub allow_origin: String, + /// Live count requests (pings and reads) an IP address may make an hour, with a burst of a + /// tenth of it. A game pings once a minute, so the default lets ten play behind one address. + pub live_per_hour: f64, + /// How long a session counts after its last ping. + pub live_window: Duration, + /// Sessions the live count holds at most; a new one past that is refused. + pub live_max_sessions: usize, } impl Default for Config { @@ -49,13 +62,17 @@ impl Default for Config { items_per_day: 100, trust_proxy: false, allow_origin: "https://playhorizon.dev".into(), + live_per_hour: 600.0, + live_window: horizon_telemetry::live::WINDOW, + live_max_sessions: 200_000, } } } impl Config { /// The defaults, with `HORIZON_TELEMETRY_RETENTION_DAYS`, `…_MIN_INSTALL_DAYS`, - /// `…_REQUESTS_PER_HOUR`, `…_ITEMS_PER_DAY`, `…_TRUST_PROXY` (1) and `…_ALLOW_ORIGIN`. + /// `…_REQUESTS_PER_HOUR`, `…_ITEMS_PER_DAY`, `…_TRUST_PROXY` (1), `…_ALLOW_ORIGIN`, + /// `…_LIVE_PER_HOUR`, `…_LIVE_WINDOW_SECONDS` and `…_LIVE_MAX_SESSIONS`. pub fn from_env() -> Self { let var = |k: &str| std::env::var(format!("HORIZON_TELEMETRY_{k}")).ok(); let d = Self::default(); @@ -74,6 +91,15 @@ impl Config { .unwrap_or(d.items_per_day), trust_proxy: var("TRUST_PROXY").is_some_and(|v| v == "1"), allow_origin: var("ALLOW_ORIGIN").unwrap_or(d.allow_origin), + live_per_hour: var("LIVE_PER_HOUR") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.live_per_hour), + live_window: var("LIVE_WINDOW_SECONDS") + .and_then(|v| v.parse().ok()) + .map_or(d.live_window, Duration::from_secs), + live_max_sessions: var("LIVE_MAX_SESSIONS") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.live_max_sessions), } } } @@ -82,13 +108,21 @@ pub struct App { store: Mutex, config: Config, /// Tokens left and when they were counted, per client address. - buckets: Mutex>, + buckets: Mutex, + /// The same for the live count's requests. + live_buckets: Mutex, + /// Who is playing now (`live`). + live: Mutex, + /// `GET /v1/live`'s answer and when it was made. + live_cache: Mutex>, /// `/v1/aggregate` answers by `days`, with when they were made. cache: Mutex>, /// Today, in days since 1970 (a test moves it). today: Box u32 + Send + Sync>, } +type Buckets = HashMap; + const CACHE_FOR: Duration = Duration::from_secs(600); fn status(code: StatusCode, message: &str) -> Response { @@ -105,10 +139,14 @@ impl App { config: Config, today: Box u32 + Send + Sync>, ) -> Arc { + let live = Live::new(config.live_window, config.live_max_sessions); Arc::new(Self { store: Mutex::new(store), config, buckets: Mutex::default(), + live_buckets: Mutex::default(), + live: Mutex::new(live), + live_cache: Mutex::default(), cache: Mutex::default(), today, }) @@ -120,18 +158,16 @@ impl App { /// Takes a token for `ip`; false when it has none left. fn allow(&self, ip: IpAddr) -> bool { - let rate = self.config.requests_per_hour / 3600.0; - let burst = (self.config.requests_per_hour / 10.0).max(1.0); - let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner()); - let now = Instant::now(); - let (tokens, at) = buckets.entry(ip).or_insert((burst, now)); - *tokens = (*tokens + now.duration_since(*at).as_secs_f64() * rate).min(burst); - *at = now; - if *tokens < 1.0 { - return false; - } - *tokens -= 1.0; - true + take(&self.buckets, self.config.requests_per_hour, ip) + } + + /// [`Self::allow`] for the live count's requests. + fn allow_live(&self, ip: IpAddr) -> bool { + take(&self.live_buckets, self.config.live_per_hour, ip) + } + + fn live(&self) -> std::sync::MutexGuard<'_, Live> { + self.live.lock().unwrap_or_else(|e| e.into_inner()) } fn client(&self, headers: &HeaderMap, peer: Option) -> IpAddr { @@ -160,14 +196,18 @@ impl App { self.store().newest_day(install).ok().flatten() } - /// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries forgotten. + /// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries and live + /// sessions forgotten. pub fn maintain(&self) -> rusqlite::Result { let oldest = (self.today)().saturating_sub(self.config.retention_days); let removed = self.store().expire(oldest)?; - self.buckets - .lock() - .unwrap_or_else(|e| e.into_inner()) - .retain(|_, (_, at)| at.elapsed() < Duration::from_secs(3600)); + for buckets in [&self.buckets, &self.live_buckets] { + buckets + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|_, (_, at)| at.elapsed() < Duration::from_secs(3600)); + } + self.live().forget_expired(Instant::now()); if removed > 0 { self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear(); } @@ -175,6 +215,23 @@ impl App { } } +/// Takes a token for `ip` from its bucket in `buckets` (`per_hour`, bursts of a tenth of it); +/// false when it has none left. +fn take(buckets: &Mutex, per_hour: f64, ip: IpAddr) -> bool { + let rate = per_hour / 3600.0; + let burst = (per_hour / 10.0).max(1.0); + let mut buckets = buckets.lock().unwrap_or_else(|e| e.into_inner()); + let now = Instant::now(); + let (tokens, at) = buckets.entry(ip).or_insert((burst, now)); + *tokens = (*tokens + now.duration_since(*at).as_secs_f64() * rate).min(burst); + *at = now; + if *tokens < 1.0 { + return false; + } + *tokens -= 1.0; + true +} + fn peer(req: &Request) -> Option { req.extensions() .get::>() @@ -278,18 +335,65 @@ async fn aggregate(State(app): State>, Query(window): Query) -> } } }; + json_response(&app, json, CACHE_FOR.as_secs()) +} + +/// `POST /v1/live`: one "I'm playing". 204, or 400 for a bad ping, 413 for one too large, 429 +/// over the address's limit, 503 when the table is full. +async fn ping(State(app): State>, req: Request) -> Response { + if !app.allow_live(app.client(req.headers(), peer(&req))) { + return status(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + let Ok(body) = axum::body::to_bytes(req.into_body(), MAX_PING_BYTES).await else { + return status(StatusCode::PAYLOAD_TOO_LARGE, "a ping is at most 512 bytes"); + }; + let ping: Ping = match serde_json::from_slice(&body) { + Ok(ping) => ping, + Err(e) => return status(StatusCode::BAD_REQUEST, &e.to_string()), + }; + if let Err(e) = ping.check() { + return status(StatusCode::BAD_REQUEST, e); + } + match app.live().ping(&ping, Instant::now()) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(live::Full) => status(StatusCode::SERVICE_UNAVAILABLE, "try again later"), + } +} + +/// `GET /v1/live`: the rounded live count (`horizon_telemetry::live::Count`), made again at +/// most every eighth of the window. +async fn playing(State(app): State>, req: Request) -> Response { + if !app.allow_live(app.client(req.headers(), peer(&req))) { + return status(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + let fresh_for = app.config.live_window / 8; + let json = { + let mut cache = app.live_cache.lock().unwrap_or_else(|e| e.into_inner()); + match &*cache { + Some((at, json)) if at.elapsed() < fresh_for => json.clone(), + _ => { + let json = + serde_json::to_string(&app.live().count(Instant::now())).unwrap_or_default(); + *cache = Some((Instant::now(), json.clone())); + json + } + } + }; + json_response(&app, json, fresh_for.as_secs()) +} + +fn json_response(app: &App, json: String, max_age: u64) -> Response { let origin = HeaderValue::from_str(&app.config.allow_origin).unwrap_or(HeaderValue::from_static("*")); + let cache = HeaderValue::from_str(&format!("public, max-age={max_age}")) + .unwrap_or(HeaderValue::from_static("no-cache")); ( [ ( header::CONTENT_TYPE, HeaderValue::from_static("application/json"), ), - ( - header::CACHE_CONTROL, - HeaderValue::from_static("public, max-age=600"), - ), + (header::CACHE_CONTROL, cache), (header::ACCESS_CONTROL_ALLOW_ORIGIN, origin), ], json, @@ -300,6 +404,7 @@ async fn aggregate(State(app): State>, Query(window): Query) -> pub fn router(app: Arc) -> Router { Router::new() .route("/v1/batch", post(receive)) + .route("/v1/live", post(ping).get(playing)) .route("/v1/installs/{id}", delete(forget)) .route("/v1/aggregate", get(aggregate)) .route("/healthz", get(|| async { "ok" })) diff --git a/crates/horizon-telemetry-server/src/live.rs b/crates/horizon-telemetry-server/src/live.rs new file mode 100644 index 000000000..f28165da5 --- /dev/null +++ b/crates/horizon-telemetry-server/src/live.rs @@ -0,0 +1,201 @@ +//! Who is playing now, from the games' "I'm playing" pings (`horizon_telemetry::live`). Kept in +//! memory only: a session ID, when it last pinged, its version, platform and LAN flag. A +//! session counts while its last ping is under the window old and is forgotten after it. No +//! address is kept, and nothing is written to disk, so a restart starts the count again (it +//! is whole again a minute later). +use std::collections::{BTreeMap, HashMap}; +use std::time::{Duration, Instant}; + +use horizon_telemetry::live::{Count, PLATFORMS, Ping, round}; + +/// Versions listed by name in a count; the rest are summed under `other`. +const VERSIONS_SHOWN: usize = 12; + +struct Seen { + at: Instant, + version: Box, + platform: u8, + lan: bool, +} + +pub struct Live { + sessions: HashMap, + window: Duration, + max_sessions: usize, +} + +/// The table is full and this session is new. +#[derive(Debug, PartialEq, Eq)] +pub struct Full; + +impl Live { + pub fn new(window: Duration, max_sessions: usize) -> Self { + Self { + sessions: HashMap::new(), + window, + max_sessions, + } + } + + /// Records `ping` (already checked) as seen at `now`. + pub fn ping(&mut self, ping: &Ping, now: Instant) -> Result<(), Full> { + let Ok(id) = u128::from_str_radix(&ping.session, 16) else { + return Ok(()); + }; + if !self.sessions.contains_key(&id) && self.sessions.len() >= self.max_sessions { + self.forget_expired(now); + if self.sessions.len() >= self.max_sessions { + return Err(Full); + } + } + let platform = PLATFORMS + .iter() + .position(|p| *p == ping.platform) + .unwrap_or(PLATFORMS.len() - 1) as u8; + self.sessions.insert( + id, + Seen { + at: now, + version: ping.version.as_str().into(), + platform, + lan: ping.lan, + }, + ); + Ok(()) + } + + /// Drops the sessions whose last ping is older than the window. + pub fn forget_expired(&mut self, now: Instant) { + let window = self.window; + self.sessions + .retain(|_, s| now.saturating_duration_since(s.at) < window); + } + + /// Who is playing at `now`, rounded. + pub fn count(&mut self, now: Instant) -> Count { + self.forget_expired(now); + let mut versions: HashMap<&str, u64> = HashMap::new(); + let mut platforms = [0u64; PLATFORMS.len()]; + let mut lan = 0; + for s in self.sessions.values() { + *versions.entry(&s.version).or_default() += 1; + platforms[usize::from(s.platform)] += 1; + lan += u64::from(s.lan); + } + let mut by_count: Vec<(&str, u64)> = versions.into_iter().collect(); + by_count.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); + let mut shown = BTreeMap::new(); + let mut other = 0; + for (i, (version, n)) in by_count.into_iter().enumerate() { + if i < VERSIONS_SHOWN { + shown.insert(version.to_owned(), round(n)); + } else { + other += n; + } + } + if other > 0 { + *shown.entry("other".to_owned()).or_default() += round(other); + } + Count { + playing: round(self.sessions.len() as u64), + lan: round(lan), + versions: shown, + platforms: PLATFORMS + .iter() + .zip(platforms) + .filter(|(_, n)| *n > 0) + .map(|(p, n)| ((*p).to_owned(), round(n))) + .collect(), + } + } + + /// Sessions held now, counted or not yet forgotten. + pub fn len(&self) -> usize { + self.sessions.len() + } + + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ping(n: u32, version: &str, platform: &str, lan: bool) -> Ping { + Ping { + session: format!("{n:032x}"), + version: version.into(), + platform: platform.into(), + lan, + } + } + + #[test] + fn sessions_count_until_the_window_passes() { + let t = Instant::now(); + let mut live = Live::new(Duration::from_secs(120), 100); + live.ping(&ping(1, "0.2.0", "linux", false), t).unwrap(); + live.ping(&ping(2, "0.2.0", "macos", true), t).unwrap(); + // The same session again is still one. + live.ping( + &ping(1, "0.2.0", "linux", false), + t + Duration::from_secs(60), + ) + .unwrap(); + let count = live.count(t + Duration::from_secs(61)); + assert_eq!((count.playing, count.lan), (2, 1)); + assert_eq!(count.versions, BTreeMap::from([("0.2.0".into(), 2)])); + assert_eq!( + count.platforms, + BTreeMap::from([("linux".into(), 1), ("macos".into(), 1)]) + ); + // Session 2 stopped pinging two minutes ago; session 1 pinged a minute later. + let count = live.count(t + Duration::from_secs(150)); + assert_eq!((count.playing, count.lan), (1, 0)); + assert_eq!(live.len(), 1); + assert_eq!(live.count(t + Duration::from_secs(181)), Count::default()); + assert!(live.is_empty()); + } + + #[test] + fn a_full_table_takes_no_new_session_until_one_expires() { + let t = Instant::now(); + let mut live = Live::new(Duration::from_secs(120), 2); + live.ping(&ping(1, "0.2.0", "linux", false), t).unwrap(); + live.ping(&ping(2, "0.2.0", "linux", false), t).unwrap(); + assert_eq!(live.ping(&ping(3, "0.2.0", "linux", false), t), Err(Full)); + // A known session still refreshes. + live.ping( + &ping(1, "0.2.0", "linux", false), + t + Duration::from_secs(100), + ) + .unwrap(); + let later = t + Duration::from_secs(130); + live.ping(&ping(3, "0.2.0", "linux", false), later).unwrap(); + assert_eq!(live.count(later).playing, 2); + } + + #[test] + fn rare_versions_are_summed_and_counts_rounded() { + let t = Instant::now(); + let mut live = Live::new(Duration::from_secs(120), 1000); + for n in 0..40 { + live.ping(&ping(n, "0.2.0", "windows", n % 4 == 0), t) + .unwrap(); + } + for n in 0..(VERSIONS_SHOWN as u32 + 3) { + live.ping(&ping(100 + n, &format!("dev {n}"), "other", false), t) + .unwrap(); + } + let count = live.count(t); + assert_eq!(count.playing, 55); + assert_eq!(count.lan, 10); + assert_eq!(count.versions["0.2.0"], 40); + assert_eq!(count.versions["other"], 4); + assert_eq!(count.versions.len(), VERSIONS_SHOWN + 1); + assert_eq!(count.platforms["windows"], 40); + assert_eq!(count.platforms["other"], 15); + } +} diff --git a/crates/horizon-telemetry-server/tests/live.rs b/crates/horizon-telemetry-server/tests/live.rs new file mode 100644 index 000000000..d8c7f33fe --- /dev/null +++ b/crates/horizon-telemetry-server/tests/live.rs @@ -0,0 +1,190 @@ +//! The live count through its HTTP interface, and end to end with the game's ping and the +//! launcher's read (`horizon_telemetry::live`). +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use horizon_telemetry::live::{self, Count, Ping}; +use horizon_telemetry_server::store::Store; +use horizon_telemetry_server::{App, Config, router}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +fn app(config: Config) -> Arc { + App::new(Store::open(":memory:").unwrap(), config) +} + +fn quick() -> Config { + Config { + live_per_hour: 100_000.0, + live_window: Duration::from_secs(1), + trust_proxy: true, + ..Config::default() + } +} + +fn ping(n: u32, version: &str, lan: bool) -> String { + serde_json::to_string(&Ping { + session: format!("{n:032x}"), + version: version.into(), + platform: "linux".into(), + lan, + }) + .unwrap() +} + +async fn send(app: &Arc, req: Request) -> (StatusCode, axum::http::HeaderMap, String) { + let res = router(app.clone()).oneshot(req).await.unwrap(); + let (code, headers) = (res.status(), res.headers().clone()); + let body = res.into_body().collect().await.unwrap().to_bytes(); + (code, headers, String::from_utf8_lossy(&body).into_owned()) +} + +async fn post(app: &Arc, body: impl Into, from: &str) -> StatusCode { + let req = Request::post("/v1/live") + .header("x-forwarded-for", from) + .body(body.into()) + .unwrap(); + send(app, req).await.0 +} + +async fn count(app: &Arc) -> Count { + let req = Request::get("/v1/live") + .header("x-forwarded-for", "198.51.100.1") + .body(Body::empty()) + .unwrap(); + let (code, headers, body) = send(app, req).await; + assert_eq!(code, StatusCode::OK, "{body}"); + assert_eq!(headers[header::CONTENT_TYPE], "application/json"); + assert_eq!( + headers[header::ACCESS_CONTROL_ALLOW_ORIGIN], + "https://playhorizon.dev" + ); + serde_json::from_str(&body).unwrap() +} + +#[tokio::test] +async fn sessions_count_while_they_ping_and_expire_after() { + let app = app(quick()); + assert_eq!( + post(&app, ping(1, "0.2.0", false), "203.0.113.1").await, + StatusCode::NO_CONTENT + ); + assert_eq!( + post(&app, ping(2, "0.2.0", true), "203.0.113.2").await, + StatusCode::NO_CONTENT + ); + // A second ping from the same session is still one player. + assert_eq!( + post(&app, ping(1, "0.2.0", false), "203.0.113.1").await, + StatusCode::NO_CONTENT + ); + let now = count(&app).await; + assert_eq!((now.playing, now.lan), (2, 1)); + assert_eq!(now.versions["0.2.0"], 2); + assert_eq!(now.platforms["linux"], 2); + + // Session 1 keeps pinging; session 2 stops. + for _ in 0..5 { + tokio::time::sleep(Duration::from_millis(250)).await; + post(&app, ping(1, "0.2.0", false), "203.0.113.1").await; + } + let later = count(&app).await; + assert_eq!((later.playing, later.lan), (1, 0)); + tokio::time::sleep(Duration::from_millis(1200)).await; + assert_eq!(count(&app).await, Count::default()); +} + +#[tokio::test] +async fn bad_pings_are_refused() { + let app = app(quick()); + let from = "203.0.113.3"; + assert_eq!(post(&app, "not json", from).await, StatusCode::BAD_REQUEST); + assert_eq!( + post(&app, ping(1, "0.2", false), from).await, + StatusCode::BAD_REQUEST + ); + let extra = r#"{"session":"00000000000000000000000000000001","version":"0.2.0","platform":"linux","lan":false,"name":"Steve"}"#; + assert_eq!(post(&app, extra, from).await, StatusCode::BAD_REQUEST); + assert_eq!( + post(&app, "x".repeat(4096), from).await, + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!(count(&app).await.playing, 0); +} + +#[tokio::test] +async fn an_address_is_rate_limited_on_its_own_bucket() { + let app = app(Config { + live_per_hour: 60.0, + trust_proxy: true, + ..Config::default() + }); + // A burst of a tenth of the hour's 60, then 429. + let mut codes = Vec::new(); + for n in 0..8 { + codes.push(post(&app, ping(n, "0.2.0", false), "203.0.113.4").await); + } + assert_eq!( + codes + .iter() + .filter(|c| **c == StatusCode::NO_CONTENT) + .count(), + 6 + ); + assert_eq!(codes[7], StatusCode::TOO_MANY_REQUESTS); + // Another address is not held back. + assert_eq!( + post(&app, ping(9, "0.2.0", false), "203.0.113.5").await, + StatusCode::NO_CONTENT + ); +} + +#[tokio::test] +async fn a_full_table_refuses_new_sessions() { + let app = app(Config { + live_max_sessions: 2, + ..quick() + }); + for n in 0..2 { + assert_eq!( + post(&app, ping(n, "0.2.0", false), "203.0.113.6").await, + StatusCode::NO_CONTENT + ); + } + assert_eq!( + post(&app, ping(2, "0.2.0", false), "203.0.113.6").await, + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!( + post(&app, ping(1, "0.2.0", false), "203.0.113.6").await, + StatusCode::NO_CONTENT + ); +} + +#[tokio::test] +async fn the_games_ping_and_the_launchers_read_reach_it_end_to_end() { + let app = app(Config { + live_window: Duration::from_secs(120), + ..quick() + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let service = router(app.clone()).into_make_service_with_connect_info::(); + tokio::spawn(async move { axum::serve(listener, service).await.unwrap() }); + + let count = tokio::task::spawn_blocking(move || { + let a = Ping::new(&live::session_id(), "0.2.0", false); + let b = Ping::new(&live::session_id(), "dev 0.1.0", true); + live::send(&url, &a)?; + live::send(&url, &b)?; + live::fetch(&url) + }) + .await + .unwrap() + .unwrap(); + assert_eq!((count.playing, count.lan), (2, 1)); + assert_eq!(count.versions.len(), 2); + assert_eq!(count.platforms[live::platform()], 2); +} diff --git a/crates/horizon-telemetry/Cargo.toml b/crates/horizon-telemetry/Cargo.toml index ea54b079d..913eac453 100644 --- a/crates/horizon-telemetry/Cargo.toml +++ b/crates/horizon-telemetry/Cargo.toml @@ -13,12 +13,14 @@ serde_json = "1" ruzstd = "0.9" # The random install ID and outbox file names. getrandom = "0.3" -# Sending, for the launcher; the ingest server builds without it. +# Sending, for the launcher (and the game's "I'm playing" ping); the ingest server builds without it. ureq = { version = "2", default-features = false, features = ["tls"], optional = true } [features] -default = ["upload"] +default = ["upload", "live"] upload = ["dep:ureq"] +# The "I'm playing" ping and reading the live count (`live`), for the game and the launcher. +live = ["dep:ureq"] [[test]] name = "consent_gating" diff --git a/crates/horizon-telemetry/src/lib.rs b/crates/horizon-telemetry/src/lib.rs index e903936db..8d2840a08 100644 --- a/crates/horizon-telemetry/src/lib.rs +++ b/crates/horizon-telemetry/src/lib.rs @@ -6,11 +6,15 @@ //! connection. Items are small summaries (percentiles and histogram buckets, never raw streams), //! dated to the day, with no account, world, server or address in them ([`scrub`]); they leave //! in zstd batches ([`batch`]). `docs/telemetry.md` lists every field. +//! +//! The one exception is [`live`]: the anonymous "I'm playing" ping the game sends while it +//! runs, for the live player count. It holds no install ID and nothing about the player. use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; pub mod batch; pub mod consent; +pub mod live; pub mod outbox; pub mod payload; pub mod scrub; @@ -29,8 +33,9 @@ pub const SCHEMA: u32 = 1; /// and until the player answers nothing new is kept or sent. pub const CONSENT_VERSION: u32 = 1; -/// The ingest server, or none while there isn't one: then nothing is ever sent and the outbox -/// stays within its caps. `HORIZON_TELEMETRY_URL` overrides it (tests, a local server). +/// The ingest server, or none while there isn't one: then nothing is ever sent (no ping either) +/// and the outbox stays within its caps. `HORIZON_TELEMETRY_URL` overrides it (tests, a local +/// server). pub const DEFAULT_ENDPOINT: Option<&str> = None; /// Where items are sent, if anywhere. diff --git a/crates/horizon-telemetry/src/live.rs b/crates/horizon-telemetry/src/live.rs new file mode 100644 index 000000000..b1df57320 --- /dev/null +++ b/crates/horizon-telemetry/src/live.rs @@ -0,0 +1,215 @@ +//! The anonymous "I'm playing" ping, and the live count it feeds ("N playing now" in the +//! launcher). +//! +//! Unlike everything else in this crate it does not wait for consent: Ludovic decided +//! (2026-10-22) to count every player and tell them, in the launcher's first-run question, its +//! Settings and the privacy page. So a ping holds only what the count needs: a random session ID +//! made when the game starts and kept only in its memory (not the install ID), the Horizon +//! version, the platform and whether a LAN or play-with-friends world is open. The game sends +//! one every [`INTERVAL`]; the server counts a session while its last ping is under +//! [`WINDOW`] old, keeps nothing on disk and serves the counts rounded ([`round`]). +use std::collections::BTreeMap; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// How often the game pings while it runs. +pub const INTERVAL: Duration = Duration::from_secs(60); + +/// How long a session counts after its last ping: two pings, so one lost on the way doesn't +/// drop it. +pub const WINDOW: Duration = Duration::from_secs(120); + +/// The platforms a ping may name; anything else is `other`. +pub const PLATFORMS: [&str; 4] = ["linux", "macos", "windows", "other"]; + +/// The largest ping body the server reads. +pub const MAX_PING_BYTES: usize = 512; + +const MAX_VERSION: usize = 40; + +/// One "I'm playing", as it is sent: `{"session":"…","version":"…","platform":"…","lan":false}`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Ping { + /// 128 random bits in hex, made when the game starts ([`session_id`]) and never written down. + pub session: String, + /// The release (`v2026.10.15`, `0.2.0`) or `dev 0.1.0` for a build from source. + pub version: String, + /// One of [`PLATFORMS`]. + pub platform: String, + /// A world is open to LAN or to friends over the internet. + pub lan: bool, +} + +impl Ping { + pub fn new(session: &str, version: &str, lan: bool) -> Self { + Self { + session: session.to_owned(), + version: version.to_owned(), + platform: platform().to_owned(), + lan, + } + } + + /// Why the server refuses this ping, if it does. + pub fn check(&self) -> Result<(), &'static str> { + if !crate::consent::is_install_id(&self.session) { + return Err("session: 32 lowercase hex digits"); + } + let version_ok = !self.version.is_empty() + && self.version.len() <= MAX_VERSION + && self + .version + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b" .+-_".contains(&b)); + if !version_ok { + return Err("version: at most 40 letters, digits, spaces and .+-_"); + } + if !PLATFORMS.contains(&self.platform.as_str()) { + return Err("platform: linux, macos, windows or other"); + } + Ok(()) + } +} + +/// A new session ID: 128 random bits in hex. +pub fn session_id() -> String { + crate::random_hex(16) +} + +/// This machine's entry in [`PLATFORMS`]. +pub fn platform() -> &'static str { + match std::env::consts::OS { + os @ ("linux" | "macos" | "windows") => os, + _ => "other", + } +} + +/// The live count, as `GET /v1/live` serves it: every number already [`round`]ed. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Count { + /// Sessions that pinged within the window. + pub playing: u64, + /// Of those, the ones with a LAN or play-with-friends world open. + pub lan: u64, + /// Per version: the most played ones, the rest summed under `other`. + pub versions: BTreeMap, + /// Per platform. + pub platforms: BTreeMap, +} + +/// Rounds a count for showing: exact under 10, then to the nearest 5 under 100, the nearest 10 +/// under 1000 and the nearest 100 above. Small numbers stay true; large ones don't pretend to +/// a precision a two-minute window doesn't have. +pub fn round(n: u64) -> u64 { + let step = match n { + 0..10 => return n, + 10..100 => 5, + 100..1000 => 10, + _ => 100, + }; + (n + step / 2) / step * step +} + +/// Sends `ping` to `endpoint` (`https://host`, without a trailing slash). +#[cfg(feature = "live")] +pub fn send(endpoint: &str, ping: &Ping) -> Result<(), String> { + agent() + .post(&format!("{endpoint}/v1/live")) + .set("Content-Type", "application/json") + .send_string(&serde_json::to_string(ping).map_err(|e| e.to_string())?) + .map(drop) + .map_err(|e| e.to_string()) +} + +/// The live count at `endpoint`. +#[cfg(feature = "live")] +pub fn fetch(endpoint: &str) -> Result { + let body = agent() + .get(&format!("{endpoint}/v1/live")) + .call() + .map_err(|e| e.to_string())? + .into_string() + .map_err(|e| e.to_string())?; + serde_json::from_str(&body).map_err(|e| e.to_string()) +} + +#[cfg(feature = "live")] +fn agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout(Duration::from_secs(5)) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ping() -> Ping { + Ping::new(&session_id(), "v2026.10.15", false) + } + + #[test] + fn a_ping_holds_only_the_four_fields() { + let json = serde_json::to_value(Ping::new(&"a".repeat(32), "0.2.0", true)).unwrap(); + let mut keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(|k| k.as_str()) + .collect(); + keys.sort_unstable(); + assert_eq!(keys, ["lan", "platform", "session", "version"]); + assert!(serde_json::to_string(&ping()).unwrap().len() < MAX_PING_BYTES); + } + + #[test] + fn session_ids_are_fresh_each_time() { + let (a, b) = (session_id(), session_id()); + assert_ne!(a, b); + assert!(ping().check().is_ok()); + } + + #[test] + fn bad_pings_are_refused() { + let bad = |f: fn(&mut Ping)| { + let mut p = ping(); + f(&mut p); + p.check().is_err() + }; + assert!(bad(|p| p.session = "ABC".into())); + assert!(bad(|p| p.session = "G".repeat(32))); + assert!(bad(|p| p.version = String::new())); + assert!(bad(|p| p.version = "1".repeat(41))); + assert!(bad(|p| p.version = "