Merge origin/main into feat/update-channels (Settings: keep update_channel and main's ping_notice_seen)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-22 15:19:31 +02:00
commit b197682dc8
62 files changed
+2511 -332

No files matched your search

+3 -2
View File
@@ -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"] }
@@ -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;
+62 -11
View File
@@ -902,6 +902,8 @@ struct WorldRest {
behaviours: Behaviours,
player_arm: Option<horizon_client::mobs::Part>,
player_type: Option<Arc<horizon_client::mobs::MobType>>,
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
spin_attack: Option<horizon_client::mobs::Part>,
stations: horizon_client::stations::StationData,
}
@@ -977,6 +979,9 @@ impl Resources {
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
&self.rest().player_type
}
fn spin_attack(&self) -> &Option<horizon_client::mobs::Part> {
&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<String> = 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());
+1 -1
View File
@@ -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::<serde_json::Map<_, _>>(),
+11 -4
View File
@@ -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 {
+49 -75
View File
@@ -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<super::lan_actions::GuestCommand>,
/// 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<u8> {
/// 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<horizon_protocol::item::Stack>)> {
fn mob_equipment(
m: &Mob,
hub: &mut Hub,
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
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<horizon_protocol::it
});
out.push((7, hub.stack(&Some(stack))));
}
out
}
pub const HELP: &[&str] = &[
@@ -537,6 +540,7 @@ impl Game<'_> {
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<i32>, hub: &mut Hub) -> Vec<EntityState> {
let mut out = Vec::with_capacity(self.mobs.len() + self.dropped.len() + self.orbs.len());
fn lan_entities(&self, host_entity: Option<i32>, 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
};
@@ -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));
}
}
@@ -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<AttributeSnapshot> {
pub(super) fn mob_attributes(&self, m: &Mob, out: &mut Vec<AttributeSnapshot>) {
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
@@ -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<i32>,
hub: &mut Hub,
out: &mut Vec<EntityState>,
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<i32>,
hub: &mut Hub,
out: &mut Vec<EntityState>,
) {
fn lan_projectile_states(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
let owner = |who: Option<horizon_client::mobs::Who>| match who {
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0),
+1 -6
View File
@@ -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<String> = 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
@@ -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<f64> {
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.
@@ -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<horizon_client::mesher::Vertex>,
) {
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::<u8>().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(
+57 -6
View File
@@ -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<Event>,
backlog_since: Option<Instant>,
}
/// 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(())
}
+35
View File
@@ -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<u32>) {
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;
}
+28
View File
@@ -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| {
+74
View File
@@ -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<Thread> = 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);
}
}
+92 -1
View File
@@ -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<EntityState>,
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<EntityState> for States {
fn extend<I: IntoIterator<Item = EntityState>>(&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())
+1
View File
@@ -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;
+2
View File
@@ -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,
+8 -1
View File
@@ -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;
@@ -691,33 +691,37 @@ pub struct StructureAt {
}
/// `NetherFortressStructure.FORTRESS_ENEMIES`.
pub fn fortress_enemies() -> Vec<SpawnEntry> {
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<Vec<SpawnEntry>> = 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: NaturalLevel>(
l: &L,
category: Category,
x: i32,
y: i32,
z: i32,
) -> Vec<SpawnEntry> {
) -> &[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<L: NaturalLevel>(
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<L: NaturalLevel, R: RandomSource>(
{
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;
+79
View File
@@ -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<u32>,
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<MotionEvent>,
pub attributes: LiveAttributes<Box<dyn FnMut() -> 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")
+6 -2
View File
@@ -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 {
+88 -12
View File
@@ -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<Vec<(&'static str, f32)>> = const { RefCell::new(Vec::new()) };
static SPANS: RefCell<Vec<Span>> = 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<T>(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<Duration> {
None
}
/// The frame's spans, summed by name, slowest first; clears them.
pub fn take() -> Vec<(&'static str, f32)> {
pub fn take() -> Vec<Span> {
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<Span> = 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<String> = 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)]
+2 -1
View File
@@ -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};
+2 -1
View File
@@ -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))
+74 -2
View File
@@ -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<String, Value>,
/// The mods named by its `ctov:modular_compat` processors that are
/// ported along ([`Self::mark_loaded`]).
pub compat_loaded: BTreeSet<String>,
}
/// 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<String>) {
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<R: Read + Seek>(zip: &mut zip::ZipArchive<R>) -> 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<Vec<Value>> {
"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();
+174 -6
View File
@@ -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<String, usize>) -> 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<Value> = 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<String, Vec<String>> = 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()));
+8 -1
View File
@@ -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/<id>` | 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
+131 -26
View File
@@ -3,12 +3,15 @@
//! - `POST /v1/batch`: a zstd batch from the launcher, checked by `horizon_telemetry::batch`.
//! - `DELETE /v1/installs/<id>`: 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<Store>,
config: Config,
/// Tokens left and when they were counted, per client address.
buckets: Mutex<HashMap<IpAddr, (f64, Instant)>>,
buckets: Mutex<Buckets>,
/// The same for the live count's requests.
live_buckets: Mutex<Buckets>,
/// Who is playing now (`live`).
live: Mutex<Live>,
/// `GET /v1/live`'s answer and when it was made.
live_cache: Mutex<Option<(Instant, String)>>,
/// `/v1/aggregate` answers by `days`, with when they were made.
cache: Mutex<HashMap<u32, (Instant, String)>>,
/// Today, in days since 1970 (a test moves it).
today: Box<dyn Fn() -> u32 + Send + Sync>,
}
type Buckets = HashMap<IpAddr, (f64, Instant)>;
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<dyn Fn() -> u32 + Send + Sync>,
) -> Arc<Self> {
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<SocketAddr>) -> 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<usize> {
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<Buckets>, 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<SocketAddr> {
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
@@ -278,18 +335,65 @@ async fn aggregate(State(app): State<Arc<App>>, Query(window): Query<Window>) ->
}
}
};
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<Arc<App>>, 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<Arc<App>>, 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<Arc<App>>, Query(window): Query<Window>) ->
pub fn router(app: Arc<App>) -> 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" }))
+201
View File
@@ -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<str>,
platform: u8,
lan: bool,
}
pub struct Live {
sessions: HashMap<u128, Seen>,
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);
}
}
@@ -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> {
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<App>, req: Request<Body>) -> (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<App>, body: impl Into<Body>, 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<App>) -> 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, "<b>0.2</b>", 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::<std::net::SocketAddr>();
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);
}
+4 -2
View File
@@ -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"
+7 -2
View File
@@ -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.
+215
View File
@@ -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<String, u64>,
/// Per platform.
pub platforms: BTreeMap<String, u64>,
}
/// 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<Count, String> {
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 = "<script>".into()));
assert!(bad(|p| p.platform = "haiku".into()));
assert!(!bad(|p| p.version = "dev 0.1.0".into()));
let extra = r#"{"session":"00000000000000000000000000000000","version":"0.2.0","platform":"linux","lan":false,"name":"Steve"}"#;
assert!(serde_json::from_str::<Ping>(extra).is_err());
}
#[test]
fn counts_round_more_as_they_grow() {
let cases = [
(0, 0),
(1, 1),
(9, 9),
(10, 10),
(12, 10),
(13, 15),
(97, 95),
(98, 100),
(104, 100),
(105, 110),
(994, 990),
(995, 1000),
(1049, 1000),
(1050, 1100),
];
for (n, shown) in cases {
assert_eq!(round(n), shown, "{n}");
}
}
}
+5 -4
View File
@@ -7,10 +7,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
/// threads, as vanilla's `MinecraftServer.spin` does for its server thread
/// (`Thread.setPriority(8)` when there are more than four processors), so
/// world generation and chunk encoding on every other core don't hold a tick
/// back: user-interactive quality of service on macOS, above-normal priority
/// on Windows. Nothing elsewhere: Linux lets an unprivileged thread lower its
/// priority but not raise it (and the JVM leaves it there too). Whether the
/// OS took it.
/// back (the client asks the same for its render thread, so they don't hold
/// a frame back either): user-interactive quality of service on macOS,
/// above-normal priority on Windows. Nothing elsewhere: Linux lets an
/// unprivileged thread lower its priority but not raise it (and the JVM
/// leaves it there too). Whether the OS took it.
pub fn favour_current() -> bool {
if std::thread::available_parallelism().map_or(1, |n| n.get()) <= 4 {
return false;
@@ -122,6 +122,9 @@ impl<L: LevelAccess + ?Sized> PlacementContext for LevelFeatureWorld<'_, '_, L>
) -> Result<bool, PlacementQueryError> {
Ok(self.sim.b.has_flag(state, state_flags::COLLISION_EMPTY))
}
fn can_occlude(&mut self, state: BlockStateId) -> Result<bool, PlacementQueryError> {
Ok(self.sim.b.has_flag(state, state_flags::CAN_OCCLUDE))
}
/// BeehiveDecorator's bees go into the nest's block entity (the tree
/// checked the nest is there).
fn store_beehive_occupants(
@@ -816,6 +816,19 @@ impl PlacementContext for World<'_> {
.is_face_sturdy(self, state, position, direction, SupportType::Center)
.map_err(query)
}
fn can_occlude(&mut self, state: BlockStateId) -> Result<bool, PlacementQueryError> {
if let Some(behaviours) = self.behaviours {
return Ok(behaviours.has_flag(state, crate::level::data::state_flags::CAN_OCCLUDE));
}
let properties = &self.blocks()?.properties;
let name = properties
.registry()
.state(state)
.ok_or(PlacementQueryError::Unavailable)?
.name
.clone();
Ok(properties.definition(&name).is_some_and(|d| d.can_occlude))
}
fn collision_full_block(
&mut self,
state: BlockStateId,
@@ -211,6 +211,13 @@ pub trait PlacementContext {
fn water_at(&mut self, _position: [i32; 3]) -> Result<bool, PlacementQueryError> {
Err(PlacementQueryError::Unavailable)
}
/// `BlockState.canOcclude()` of a state.
fn can_occlude(
&mut self,
_state: crate::registry::BlockStateId,
) -> Result<bool, PlacementQueryError> {
Err(PlacementQueryError::Unavailable)
}
/// Multiface attachment: neighbor support, then empty-context collision.
fn attachment_at(
&mut self,
@@ -3,9 +3,14 @@
//! one pool element at a random spot of the chunk, at a height its
//! placement finds in the noise columns under the element's corners and
//! centre ([`StartContext::noise_column`]).
//!
//! Its PiecedStructure (the fountain) starts the same way, then lays out
//! the element's jigsaw children with vanilla's jigsaw
//! ([`super::jigsaw::add_children`]): `horizon:pieced` on the structure
//! holds its `max_depth` and `max_distance_from_center`.
use super::{
BoundingBox, Rotation, StructureResources,
jigsaw::{PoolElement, PoolPiece, Pools},
jigsaw::{JigsawConfig, PoolElement, PoolPiece, Pools, add_children},
start::{NoiseBlock, NoiseColumn, StartContext},
};
use crate::{
@@ -51,6 +56,8 @@ impl Placement {
pub struct Def {
pub pool: String,
pub placement: Placement,
/// PiecedStructure's jigsaw: (max depth, max distance from the centre).
pub pieced: Option<(i32, i32)>,
}
impl Def {
pub fn decode(v: &Value) -> Result<Self, DecodeError> {
@@ -61,11 +68,21 @@ impl Def {
.ok_or(DecodeError::Field("formations template"))?
.to_owned(),
placement: Placement::decode(v["placement"].as_str().unwrap_or_default())?,
pieced: match v.get("horizon:pieced") {
None => None,
Some(p) => {
let int = |k: &'static str| {
p[k].as_i64().map(|n| n as i32).ok_or(DecodeError::Field(k))
};
Some((int("max_depth")?, int("max_distance_from_center")?))
}
},
})
}
}
/// SimpleStructure.findGenerationPoint: the start's position and piece.
/// SimpleStructure.findGenerationPoint, or PiecedStructure's: the start's
/// position and pieces.
pub fn generate(
def: &Def,
context: &mut impl StartContext,
@@ -74,7 +91,7 @@ pub fn generate(
bindings: &FeatureBlockBindings<'_>,
random: &mut impl RandomSource,
chunk: ChunkPos,
) -> Option<([i32; 3], PoolPiece)> {
) -> Option<([i32; 3], Vec<PoolPiece>)> {
let element = pools
.get(&def.pool, resources, bindings)?
.random_template(random);
@@ -88,7 +105,7 @@ pub fn generate(
let height = find_height(def.placement, context, b, random)?;
let ground = element.ground_level_delta();
let y = height - ground + 1;
if y < context.min_y() {
if def.pieced.is_none() && y < context.min_y() {
return None;
}
let piece = PoolPiece {
@@ -101,7 +118,46 @@ pub fn generate(
// LiquidSettings.APPLY_WATERLOGGING.
waterlogging: true,
};
Some(([x, y, z], piece))
let Some((max_depth, distance)) = def.pieced else {
return Some(([x, y, z], vec![piece]));
};
// PiecedStructure.addPieces: the start sits at the element's centre, one
// above the height found, and its children may fill the box of
// `distance` around it (not clamped to the world's height) less its own.
let (cx, cy, cz) = (
(b.max[0] + b.min[0]) / 2,
height + 1,
(b.max[2] + b.min[2]) / 2,
);
let mut pieces = vec![piece];
if max_depth > 0 {
let config = JigsawConfig {
start_pool: &def.pool,
start_jigsaw: None,
max_depth,
use_expansion_hack: false,
project_to: None,
max_distance: [distance, distance],
aliases: &Default::default(),
padding: [0, 0],
waterlogging: true,
};
let container = BoundingBox::new(
[cx - distance, cy - distance, cz - distance],
[cx + distance, cy + distance, cz + distance],
);
add_children(
&config,
context,
pools,
resources,
bindings,
random,
&mut pieces,
container,
);
}
Some(([cx, cy, cz], pieces))
}
/// The four corners (min x min z, min x max z, max x max z, max x min z)
@@ -335,6 +391,15 @@ mod tests {
);
assert!(Placement::decode("floating").is_err());
let def = Def::decode(&serde_json::json!({"template": "a:b", "placement": "surface"}));
assert_eq!(def.unwrap().placement, Placement::Surface);
let def = def.unwrap();
assert_eq!((def.placement, def.pieced), (Placement::Surface, None));
let pieced = Def::decode(
&serde_json::json!({"template": "a:b", "placement": "surface",
"horizon:pieced": {"max_depth": 2, "max_distance_from_center": 116}}),
);
assert_eq!(pieced.unwrap().pieced, Some((2, 116)));
let broken = serde_json::json!({"template": "a:b", "placement": "surface",
"horizon:pieced": {"max_depth": 2}});
assert!(Def::decode(&broken).is_err());
}
}
@@ -690,25 +690,53 @@ pub fn assemble(
let [h, v] = config.max_distance;
let low_y = (cy - v).max(context.min_y() + config.padding[0]);
let high_y = (cy + v + 1).min(context.max_y() + 1 - config.padding[1]);
let free = Rc::new(RefCell::new(Free {
container: BoundingBox::new([cx - h, low_y, cz - h], [cx + h, high_y - 1, cz + h]),
removed: vec![pieces[0].bounding_box],
}));
let mut placer = Placer {
let container = BoundingBox::new([cx - h, low_y, cz - h], [cx + h, high_y - 1, cz + h]);
add_children(
config,
context,
pools,
resources,
bindings,
pieces: &mut pieces,
queue: BTreeMap::new(),
};
placer.children(0, free, 0, context, random);
while let Some(state) = placer.next() {
placer.children(state.piece, state.free, state.depth, context, random);
}
random,
&mut pieces,
container,
);
}
Some(([cx, cy, cz], pieces))
}
/// The inner JigsawPlacement.addPieces: lays out the children of
/// `pieces[0]`, a start piece already placed, to `config.max_depth`,
/// within `container` (inclusive) less the start's box. A structure with
/// its own start (Formations' pieced one) reaches the jigsaw layout here.
#[allow(clippy::too_many_arguments)]
pub(super) fn add_children(
config: &JigsawConfig<'_>,
context: &mut impl JigsawContext,
pools: &Pools,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
random: &mut impl RandomSource,
pieces: &mut Vec<PoolPiece>,
container: BoundingBox,
) {
let free = Rc::new(RefCell::new(Free {
container,
removed: vec![pieces[0].bounding_box],
}));
let mut placer = Placer {
config,
pools,
resources,
bindings,
pieces,
queue: BTreeMap::new(),
};
placer.children(0, free, 0, context, random);
while let Some(state) = placer.next() {
placer.children(state.piece, state.free, state.depth, context, random);
}
}
struct Placer<'a, 'p> {
config: &'a JigsawConfig<'a>,
pools: &'a Pools,
@@ -331,6 +331,9 @@ pub enum Processor {
/// `horizon:waterlog_in_world_water` (Repurposed Structures'): blocks
/// are waterlogged where the world holds water, dry where it does not.
WaterlogInWorldWater,
/// `horizon:mineshaft_sky_view` (Repurposed Structures' mineshafts):
/// nothing above the ocean floor but rails on solid ground.
MineshaftSkyView,
}
#[derive(Clone, Debug, Default)]
pub struct ProcessorList(pub Vec<Processor>);
@@ -827,6 +830,7 @@ impl Processor {
}
"horizon:waterlog" => Self::Waterlog,
"horizon:waterlog_in_world_water" => Self::WaterlogInWorldWater,
"horizon:mineshaft_sky_view" => Self::MineshaftSkyView,
"horizon:keep_world_block" => {
Self::KeepWorldBlock(Box::new(sea_level::KeepWorldBlock::decode(value)?))
}
@@ -1219,6 +1223,9 @@ fn process_block<W: PlacementContext>(
settings,
);
}
Processor::MineshaftSkyView => {
return sea_level::MineshaftSkyView::process(world, resources.registry, info);
}
Processor::Pick(pick) => match name(info.state) == pick.block {
true => {
let mut out = settings.with_random(info.pos, |r| pick.apply(info.clone(), r));
@@ -13,10 +13,14 @@
//! And from Repurposed Structures':
//! - `horizon:waterlog_in_world_water`: a block that can be waterlogged is
//! where the world holds water and is dry where it does not (its
//! WaterlogWhenReplacingWaterProcessor).
//! WaterlogWhenReplacingWaterProcessor);
//! - `horizon:mineshaft_sky_view`: its mineshafts are not built above the
//! ocean floor, but keep their rails on solid ground (its
//! MineshaftSkyViewProcessor).
use super::{PlaceSettings, ProcessResult, pillars::outside_chunk};
use crate::{
fluid::{FluidKind, FluidState},
heightmap::HeightmapType,
registry::{BlockRegistry, BlockStateId},
worldgen::{DecodeError, placement::PlacementContext, structure::BlockInfo},
};
@@ -152,11 +156,7 @@ impl WaterlogInWorldWater {
if !state.properties.contains_key("waterlogged") || outside_chunk(settings, info.pos) {
return Ok(Some(info));
}
let here = world.block_state(info.pos)?;
let water = registry
.state(here)
.and_then(|s| FluidState::from_block(s).ok())
.is_some_and(|f| f.kind().same_family(FluidKind::Water));
let water = world_water(world, registry, info.pos)?;
// The mod's height check (min to max build height) holds for every
// block of the chunk.
if water && world.inside_build_height(info.pos)? {
@@ -172,6 +172,56 @@ impl WaterlogInWorldWater {
}
}
/// Whether the world's block at `pos` holds water (still or flowing, or a
/// waterlogged block): `getFluidState().is(FluidTags.WATER)`.
fn world_water<W: PlacementContext>(
world: &mut W,
registry: &BlockRegistry,
pos: [i32; 3],
) -> Result<bool, crate::worldgen::placement::PlacementQueryError> {
let here = world.block_state(pos)?;
Ok(registry
.state(here)
.and_then(|s| FluidState::from_block(s).ok())
.is_some_and(|f| f.kind().same_family(FluidKind::Water)))
}
/// `horizon:mineshaft_sky_view`: MineshaftSkyViewProcessor. A plain rail
/// whose world block below can occlude is placed, waterlogged where the
/// world holds water; any other block at or above the world's
/// `OCEAN_FLOOR_WG` height in its column is not placed, so a mineshaft cut
/// by a valley or a ravine is not built in the open.
pub struct MineshaftSkyView;
impl MineshaftSkyView {
pub(super) fn process<W: PlacementContext>(
world: &mut W,
registry: &BlockRegistry,
info: BlockInfo,
) -> ProcessResult {
let [x, y, z] = info.pos;
let rail = registry
.state(info.state)
.filter(|s| s.name == "minecraft:rail");
if let Some(rail) = rail {
let below = world.block_state([x, y - 1, z])?;
if world.can_occlude(below)? {
let mut set = rail.clone();
let water = world_water(world, registry, info.pos)?;
set.properties
.insert("waterlogged".into(), water.to_string());
return Ok(Some(BlockInfo {
state: registry.id(&set).unwrap_or(info.state),
..info
}));
}
}
if y >= world.height(HeightmapType::OceanFloorWg, x, z)? {
return Ok(None);
}
Ok(Some(info))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -198,8 +248,19 @@ mod tests {
fn min_y(&mut self) -> Result<i32, PlacementQueryError> {
Ok(-64)
}
fn height(&mut self, _: HeightmapType, _: i32, _: i32) -> Result<i32, PlacementQueryError> {
Err(PlacementQueryError::Unavailable)
/// The ocean floor is at 64 everywhere.
fn height(
&mut self,
kind: HeightmapType,
_: i32,
_: i32,
) -> Result<i32, PlacementQueryError> {
assert_eq!(kind, HeightmapType::OceanFloorWg);
Ok(64)
}
/// Only stone can occlude.
fn can_occlude(&mut self, state: BlockStateId) -> Result<bool, PlacementQueryError> {
Ok(state == BlockStateId(6))
}
fn sea_level(&mut self) -> Result<i32, PlacementQueryError> {
Ok(63)
@@ -226,12 +287,13 @@ mod tests {
}
}
// 0 air, 1 cave air, 2 water, 3/4 slab dry/wet, 5 purpur slab, 6 stone.
// 0 air, 1 cave air, 2 water, 3/4 slab dry/wet, 5 purpur slab, 6 stone,
// 7/8 rail dry/wet.
fn registry() -> BlockRegistry {
let row = |name: &str, props: Value| json!([name, false, false, "", {"id": name, "properties": props}]);
let facts = json!({"defaults": {"minecraft:air": 0, "minecraft:cave_air": 1,
"minecraft:water": 2, "minecraft:prismarine_slab": 3, "minecraft:purpur_slab": 5,
"minecraft:stone": 6},
"minecraft:stone": 6, "minecraft:rail": 7},
"states": [
["minecraft:air", false, false, "", "minecraft:air"],
["minecraft:cave_air", false, false, "", "minecraft:cave_air"],
@@ -239,7 +301,9 @@ mod tests {
row("minecraft:prismarine_slab", json!({"waterlogged": "false"})),
row("minecraft:prismarine_slab", json!({"waterlogged": "true"})),
["minecraft:purpur_slab", false, false, "", "minecraft:purpur_slab"],
["minecraft:stone", false, false, "", "minecraft:stone"]]});
["minecraft:stone", false, false, "", "minecraft:stone"],
row("minecraft:rail", json!({"shape": "north_south", "waterlogged": "false"})),
row("minecraft:rail", json!({"shape": "north_south", "waterlogged": "true"}))]});
BlockRegistry::from_facts_json(facts.to_string().as_bytes()).unwrap()
}
@@ -356,4 +420,29 @@ mod tests {
assert_eq!(run(&mut sea, info(6, [3, 40, 3])), BlockStateId(6));
assert_eq!(run(&mut sea, info(4, [16, 40, 3])), BlockStateId(4));
}
#[test]
fn mineshafts_stop_at_the_ocean_floor_but_keep_grounded_rails() {
let registry = registry();
let mut sea = Sea {
world: BlockStateId(6),
marked: vec![],
ticks: vec![],
};
let run = |sea: &mut Sea, i: BlockInfo| {
MineshaftSkyView::process(sea, &registry, i)
.unwrap()
.map(|i| i.state)
};
// Over stone a rail stays, above the floor too, dry as the world is.
assert_eq!(run(&mut sea, info(8, [3, 80, 3])), Some(BlockStateId(7)));
// Anything else stops at the floor (64).
assert_eq!(run(&mut sea, info(6, [3, 63, 3])), Some(BlockStateId(6)));
assert_eq!(run(&mut sea, info(6, [3, 64, 3])), None);
assert_eq!(run(&mut sea, info(0, [3, 90, 3])), None);
// Over water a rail is like any block.
sea.world = BlockStateId(2);
assert_eq!(run(&mut sea, info(7, [3, 70, 3])), None);
assert_eq!(run(&mut sea, info(8, [3, 40, 3])), Some(BlockStateId(8)));
}
}
@@ -149,13 +149,24 @@ pub struct StructureSet {
/// and none within `chunk_count` chunks of any of several sets
/// (`"horizon:exclusion_zone"`: `other_sets`, `chunk_count`).
min_distance: Option<i64>,
avoid: Option<(Vec<String>, i32)>,
avoid: Option<Avoid>,
/// `"horizon:ring_sections"`: starts only in odd rings around the origin.
rings: Option<RingSections>,
/// `"horizon:avoid_biomes"`: no start with one of these biomes near.
avoid_biomes: Option<AvoidBiomes>,
}
/// `"horizon:exclusion_zone"`: no start within `chunk_count` chunks of a
/// start of any of `sets`. With `"their_zones": false` those sets' own
/// exclusion zones are not checked (Mo' Structures' ModStructurePlacement
/// tests the sets it avoids by their spread and frequency alone).
#[derive(Debug, Clone)]
struct Avoid {
sets: Vec<String>,
chunk_count: i32,
their_zones: bool,
}
/// YUNG's Better Strongholds' placement (BetterStrongholdsPlacement
/// .isPlacementChunk): the world is cut into rings `thickness` chunks wide,
/// the first one starting `distance_to_first_ring` chunks out; a start is
@@ -444,16 +455,15 @@ impl StructureRegistry {
min_distance: p
.get("horizon:min_distance_from_world_origin")
.and_then(Value::as_i64),
avoid: p.get("horizon:exclusion_zone").map(|z| {
(
z["other_sets"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| Some(s.as_str()?.to_owned()))
.collect(),
z["chunk_count"].as_i64().unwrap_or(0) as i32,
)
avoid: p.get("horizon:exclusion_zone").map(|z| Avoid {
sets: z["other_sets"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| Some(s.as_str()?.to_owned()))
.collect(),
chunk_count: z["chunk_count"].as_i64().unwrap_or(0) as i32,
their_zones: z["their_zones"].as_bool().unwrap_or(true),
}),
rings: p.get("horizon:ring_sections").map(RingSections::decode),
avoid_biomes: p
@@ -534,6 +544,19 @@ impl StructureRegistry {
x: i32,
z: i32,
rings: &Rings,
) -> bool {
self.structure_chunk(set, seed, x, z, rings, true)
}
/// [`Self::is_structure_chunk`]; without the set's exclusion zones when
/// `zones` is false.
fn structure_chunk(
&self,
set: &StructureSet,
seed: i64,
x: i32,
z: i32,
rings: &Rings,
zones: bool,
) -> bool {
let placed = match set.placement {
Placement::RandomSpread {
@@ -576,24 +599,28 @@ impl StructureRegistry {
return false;
}
}
if let Some((others, range)) = &set.avoid {
if others
.iter()
.any(|other| self.has_start_in_range(other, seed, x, z, *range, rings))
{
if let Some(avoid) = set.avoid.as_ref().filter(|_| zones) {
if avoid.sets.iter().any(|other| {
let range = avoid.chunk_count;
self.has_start_in_range(other, seed, x, z, range, rings, avoid.their_zones)
}) {
return false;
}
}
if set.frequency < 1. && !reduce(set.reduction, seed, set.salt, x, z, set.frequency) {
return false;
}
match &set.exclusion {
Some((other, range)) => !self.has_start_in_range(other, seed, x, z, *range, rings),
match set.exclusion.as_ref().filter(|_| zones) {
Some((other, range)) => {
!self.has_start_in_range(other, seed, x, z, *range, rings, true)
}
None => true,
}
}
/// ChunkGeneratorStructureState.hasStructureChunkInRange: whether set
/// `other` places a structure within `range` chunks of (`x`, `z`).
/// `other` places a structure within `range` chunks of (`x`, `z`),
/// checking its own exclusion zones when `zones` is true.
#[allow(clippy::too_many_arguments)]
fn has_start_in_range(
&self,
other: &str,
@@ -602,12 +629,14 @@ impl StructureRegistry {
z: i32,
range: i32,
rings: &Rings,
zones: bool,
) -> bool {
let Some(other) = self.sets.iter().find(|s| s.name == other) else {
return false;
};
(x - range..=x + range).any(|tx| {
(z - range..=z + range).any(|tz| self.is_structure_chunk(other, seed, tx, tz, rings))
(z - range..=z + range)
.any(|tz| self.structure_chunk(other, seed, tx, tz, rings, zones))
})
}
}
@@ -1144,7 +1173,7 @@ fn try_generate(
(position, legacy(vec![piece]))
}
StructureKind::Formations(formations) => {
let (position, piece) = super::formations::generate(
let (position, pieces) = super::formations::generate(
formations,
context,
&registry.pools,
@@ -1153,7 +1182,7 @@ fn try_generate(
&mut random,
chunk,
)?;
(position, vec![Piece::Pool(piece)])
(position, pieces.into_iter().map(Piece::Pool).collect())
}
StructureKind::Unsupported(_) => return None,
};
@@ -1429,6 +1458,41 @@ mod tests {
);
}
#[test]
fn exclusion_zones_may_skip_the_other_sets_own() {
let set = |avoid: Value| {
json!({"structures": [{"structure": "t:s", "weight": 1}],
"placement": {"type": "minecraft:random_spread", "spacing": 1, "separation": 0,
"salt": 1, "horizon:exclusion_zone": avoid}})
};
let r = registry(
&[("t:s", json!({"type": "minecraft:igloo", "biomes": []}))],
&[
("t:c", set(json!({"other_sets": [], "chunk_count": 0}))),
// Every chunk is b's spread chunk, and c keeps b out of all.
("t:b", set(json!({"other_sets": ["t:c"], "chunk_count": 1}))),
(
"t:full",
set(json!({"other_sets": ["t:b"], "chunk_count": 1})),
),
(
"t:spread",
set(json!({"other_sets": ["t:b"], "chunk_count": 1, "their_zones": false})),
),
],
);
let chunk = |name: &str| {
let s = r.sets.iter().find(|s| s.name == name).unwrap();
r.is_structure_chunk(s, 0, 5, -3, &Rings::new())
};
assert!(!chunk("t:b"));
assert!(
chunk("t:full"),
"b places nothing once its own zone is checked"
);
assert!(!chunk("t:spread"), "b's spread alone is in range");
}
#[test]
fn ring_sections_keep_the_odd_rings() {
let rings = RingSections::decode(
+1 -1
View File
@@ -64,7 +64,7 @@ python3 tools/graphics_bench.py --compare baseline-4080 mine-gpu # markdown ta
vscreen is a headless compositor whose frames are software-rendered (lavapipe), so its frame times measure the CPU, rasterisation included. `--offscreen` (`horizon-client --offscreen`) renders into a texture on the most powerful GPU instead of the window, which measures the RTX 4080 under the same compositor. `--distances`, `--scenes` and `--build` pick a subset or another build. Results go to `benchmarks/graphics/<label>.json`, with the load average before and after: the desktop is shared, and other sessions' builds move the numbers.
Two tools find where a slow frame goes: `HORIZON_SPIKES=MS` prints the timed spans (ticks by system, streaming, uploads, saving, renderer) of every frame slower than MS milliseconds, and a frame-pointer build (`RUSTFLAGS="-C force-frame-pointers=yes"`) profiles with `perf record --call-graph fp`.
Two tools find where a slow frame goes: `HORIZON_SPIKES=MS` prints the timed spans (ticks by system, streaming, uploads, saving, renderer) of every frame slower than MS milliseconds, with the render thread's CPU time in the frame and, after a span that mostly waited (for the GPU, a lock or a core), its CPU time (`submit 30.2/0.1`), and a frame-pointer build (`RUSTFLAGS="-C force-frame-pointers=yes"`) profiles with `perf record --call-graph fp`.
## Results
+29 -3
View File
@@ -444,6 +444,18 @@ Two cases the oracle fills in:
also spreads its structures 1.65 times as far apart as its files say, which carry now
applies. MVS went from 829 starts to 90 in a 128×128-chunk square (seed 1234): 294 from the
spacing, the rest from the start checks.
- **Mo' Structures** (LGPL-3.0; 38 files). Its `mostructures:generic` is vanilla's jigsaw
that starts only where the terrain under the 5×5 chunks around it varies by no more than
its `heightRange`: carry writes `minecraft:jigsaw` with `horizon:start_checks`
(`terrain_radius` 2, `terrain_range`). Its placement `mostructures:type` takes spacing and
separation from the mod's config, not its files; carry writes the config's defaults
(keyed by `config_key`) and the sets it avoids within 3 chunks as `horizon:exclusion_zone`
with `"their_zones": false`: like the mod, the game tests those sets by their spread and
frequency alone, not by their own exclusion zones. Its two block-ageing processors
(`jungle_rot_processor`, `simple_cobblestone`, 3 uses) are still dropped, as is its
mixin that keeps lakes out of its structures. Over chunks -64..63 that adds 13 starts at
seed 1234 (pirate ships, churches, a tavern, the castle in the sky...) and 16 at seed 2,
and every other start stays as it was.
- **Processors written in Java** are taken out of their lists (#822). The game, like
vanilla, refuses a list holding a type it does not know, and with it every pool naming the
list. The structures now generate undecorated (no pillars under them, spawners as the
@@ -482,7 +494,11 @@ Two cases the oracle fills in:
plus the name's hash, at the block, piece or structure). In a CTOV plains village at seed
1234 the farms now grow carrots, potatoes and beetroot besides wheat, and 1,655
cobblestone blocks become paths, grass, mossy cobblestone and planks. No Lithostitched processor is
dropped now (130 before), only CTOV's compat hooks for other mods inside them (142 copies).
dropped now (130 before). CTOV's compat hooks for other mods inside them (its
`modular_compat`, 142 copies) run a list of their own only when the mod they name is
loaded: carry writes that list in when the mod is ported along and takes the hook out (a
no-op, as in the mod) when it is not. In ATM10 all 142 name Nature's Spirit, which the
pack does not have.
The processors that build outside their template are carried too: Repurposed's and
Moog's pillar processors become the game's `horizon:pillar` (a column from a placeholder
block down to the ground, with the processor list they run on each of its blocks written
@@ -517,6 +533,11 @@ Two cases the oracle fills in:
the world is air, keep it" rules: around the ocean mineshaft at chunk (-18, -54), seed
1234, 650 blocks of air and cave air become prismarine and 614 more template water blocks
stay water (1,303 blocks change in its 81 chunks).
Its mineshafts' sky view processor (71 lists) is `horizon:mineshaft_sky_view`: a block
of the template at or above the world's `OCEAN_FLOOR_WG` height in its column is not
placed, so a mineshaft cut by a valley or a ravine is not built in the open, except
plain rails over a block that can occlude, which stay (waterlogged where the world holds
water). Repurposed's dropped processors go from 253 to 182.
Still taken out: Formations' biome replacements, blocks aged or propped up.
In the 128×128-chunk square (seed 1234), every spawner of a mod structure now has its mob:
in the Overworld 83 Better Dungeons spawners held the template's placeholder pig and 17 of
@@ -622,12 +643,10 @@ Two cases the oracle fills in:
- When Dungeons Arise, Structory and Towns and Towers use vanilla's types already (T&T's
namespaces are carried since the namespaces rule above).
- Still lost, written in Java:
- Formations' pieced structure (1, its one jigsaw-like type);
- Repurposed's mineshafts, mansions and monuments, Better Mineshafts and two Better
Dungeons types: Java structure types;
- the block processors written in Java
(YUNG's banners and decorations, Better Strongholds' legs, Lithostitched's block swaps);
- MO Structures (38; its library is only on CurseForge).
- **Formations** (Overworld and Nether: 50 structures, 128 pool elements) places one
template per start at a height found in the noise columns under its corners and centre: on
the surface, on water or lava, or in a cave (on its floor, under its ceiling, buried in
@@ -638,6 +657,13 @@ Two cases the oracle fills in:
39 Overworld and 672 Nether starts at seed 1234 (51 and 643 at seed 2), and leaves every
other start as it was. Its processors (biome replacements, aged blocks, bed colours,
plants) are still dropped, so its structures keep their templates' blocks.
Its one PiecedStructure, the fountain, starts the same way and then lays out the element's
jigsaw children with vanilla's jigsaw: carry writes it as `horizon:formations` with
`horizon:pieced` (`max_depth`, `max_distance_from_center`), and the game's assembler takes
the placed start piece (`jigsaw::add_children`), the children kept within that distance of
the start's centre, one block above the height found. It shares Formations' "uncommon"
structure set, so where the set now picks it, it takes the place of a statue or a house:
1 fountain at seed 1234 and 3 at seed 2 in chunks -64..63, one with a child piece.
With every structure PR above, the same square (seed 1234, chunks -64..63) holds 362
overworld starts with 22,026 pieces. CTOV has 7, Better Dungeons 72 and Better Ocean
+2 -1
View File
@@ -94,7 +94,8 @@ run at once there, and each must finish within 10 seconds however slowly its byt
- **A relay** (only if one is configured) sees the IP addresses of the host and the guests, the short
code's first half, when sessions start and end and the size of encrypted frames. It can't see the
secret, the key or the game (names, chat, the world). It logs counts every five minutes, nothing else.
- **Horizon** sees nothing: there is no Horizon service.
- **Horizon** runs no service for it. The game's anonymous "I'm playing" ping says only whether a
LAN or friends world is open, never its address, code or name ([telemetry.md](telemetry.md)).
## Identity
+25
View File
@@ -2,6 +2,8 @@
Horizon collects nothing unless the player says yes. The launcher asks once, with four separate switches that are all off until the player turns one on, and the same switches stay in Settings. The game and the launcher obey the same answer. The code is in `crates/horizon-telemetry`.
The one exception is the anonymous ["I'm playing" ping](#the-im-playing-ping) behind the launcher's "N playing now": it holds nothing about the player, and the launcher says so next to the switches.
## Consent
`telemetry/consent.json` in the game folder (`HORIZON_HOME`, else `horizon` in the platform data folder):
@@ -46,10 +48,33 @@ Never sent: account names or UUIDs, world names, server or LAN addresses, chat,
- `HORIZON_TELEMETRY_URL` sets the server. There is none by default: until there is, nothing is sent and the outbox stays within its caps.
- `SCHEMA` versions the format. New optional fields keep it. A field that changes meaning or goes bumps it, and the server keeps reading the older ones.
## The "I'm playing" ping
Ludovic decided on 2026-10-22 to count every player, with or without consent, and to tell them. The launcher's first-run question and Settings → Data collection show it as an "Always on" row next to the switches. Players who answered the question before the ping existed see the same text once on the Play page. The privacy page on playhorizon.dev says the same.
While the game runs (not a dedicated server, a benchmark or a run that quits by itself), a thread of its own (`crates/horizon-client/src/heartbeat.rs`) sends `POST /v1/live` once a minute. It also sends one at once when a world is opened to or closed from LAN or friends:
```json
{ "session": "9b1c…", "version": "v2026.10.22", "platform": "macos", "lan": false }
```
- `session` is 128 random bits made when the game starts. It is kept only in the game's memory and is not the install ID, so two sessions of one player can't be linked.
- `version` is the release, `platform` is `linux`, `macos`, `windows` or `other`, and `lan` says whether a LAN or play-with-friends world is open.
- Nothing else: no account, install ID, world, server, settings or hardware. The server checks each field (`live::Ping::check`) and refuses anything more.
The server keeps sessions in memory only, never on disk, and never with the sender's address. A session counts while its last ping is under 2 minutes old. `GET /v1/live` gives the count, rounded (`live::round`: exact under 10, then to 5, 10 and 100):
```json
{ "playing": 1240, "lan": 85, "versions": { "v2026.10.22": 1100, "other": 140 }, "platforms": { "linux": 300, "macos": 240, "windows": 700 } }
```
The launcher reads it when it opens and every minute after, and shows "N playing now" on the Play page. It shows nothing when the server can't be reached. Like the rest, there is no server by default (`HORIZON_TELEMETRY_URL`): until there is one, the game sends no ping and the launcher shows no count. `HORIZON_NO_HEARTBEAT=1` keeps automated runs out of the count.
## Server
- `POST /v1/batch`
- `DELETE /v1/installs/<install_id>`: deletes everything sent under that ID.
- `GET /v1/aggregate`: the anonymous totals the website's results page reads.
- `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count.
`crates/horizon-telemetry-server` is the server. Its [README](../crates/horizon-telemetry-server/README.md) covers storage (about 190 bytes per play session), retention (180 days of raw rows, anonymous daily totals kept), rate limits and hosting.
+56
View File
@@ -650,6 +650,32 @@ pub fn is_prepared(tag: &str) -> bool {
resources(tag).join(RESOURCES_MANIFEST).exists()
}
/// Whether a version's resources hold Minecraft's sounds, which `sounds` copies from the player's own
/// install beside the preparation. A version prepared while that copy failed (Mojang's republished 26.3
/// asset index, #1076), or one that reused such a version's resources, plays no sound at all (#1035).
pub fn has_sounds(tag: &str) -> bool {
sounds_in(&resources(tag))
}
fn sounds_in(resources: &Path) -> bool {
// What the game's sound registry reads (horizon-client sound_events.rs).
resources.join("assets/minecraft/sounds.json").is_file()
}
/// What the Play page says of a version's resources.
pub fn resources_summary(prepared: bool, sounds: bool, version: &str) -> String {
match (prepared, sounds) {
(false, _) => {
format!("Not prepared. Horizon needs the files of your own Minecraft {version}.")
}
(true, true) => format!("Prepared for Minecraft {version}"),
(true, false) => format!(
"Prepared for Minecraft {version}, without its sounds: the game will be silent. \
Prepare again to copy them from your Minecraft install."
),
}
}
/// Whether the resources `from` prepared are the ones preparing `to` would make. The output
/// depends on the Horizon version, not only on Minecraft's: the manifest records the hashes of
/// the vanilla exporters it was made with (mods/horizon/src/test/java/.../parity), and the
@@ -2098,6 +2124,8 @@ pub enum Msg {
PackLine(String),
/// A modpack step ended: the pack as it now is.
PackDone(Result<Box<crate::modpacks::Imported>, String>),
/// How many play Horizon now, or none when the server can't be reached (`telemetry`).
Playing(Option<u64>),
}
/// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`.
@@ -2189,6 +2217,10 @@ pub struct Settings {
/// builds" switch instead; [`Settings::parse`] reads its `beta_builds: true` as Nightly.
#[serde(default)]
pub update_channel: UpdateChannel,
/// The player has read what the "I'm playing" ping holds (`telemetry::PING`): on the
/// first-run question, or on the Play page once if they answered that before it existed.
#[serde(default)]
pub ping_notice_seen: bool,
}
impl Settings {
@@ -2242,6 +2274,30 @@ mod tests {
use super::*;
use std::cmp::Ordering::*;
#[test]
fn resources_without_sounds_are_reported() {
let dir = std::env::temp_dir().join(format!("horizon-no-sounds-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("assets/minecraft/textures")).unwrap();
fs::write(dir.join(RESOURCES_MANIFEST), "{}").unwrap();
assert!(!sounds_in(&dir));
fs::write(dir.join("assets/minecraft/sounds.json"), "{}").unwrap();
assert!(sounds_in(&dir));
fs::remove_dir_all(&dir).unwrap();
assert_eq!(
resources_summary(true, true, "26.3"),
"Prepared for Minecraft 26.3"
);
let silent = resources_summary(true, false, "26.3");
assert!(silent.contains("without its sounds") && silent.contains("Prepare again"));
// Not prepared says only that, sounds or not.
assert_eq!(
resources_summary(false, false, "26.3"),
resources_summary(false, true, "26.3")
);
}
#[test]
fn the_game_plays_under_its_own_audio_stream_name() {
if std::env::var_os("PIPEWIRE_PROPS").is_some() {
+4 -2
View File
@@ -429,10 +429,12 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
} else {
" "
};
let state = if backend::is_prepared(tag) {
let state = if !backend::is_prepared(tag) {
"not prepared"
} else if backend::has_sounds(tag) {
"prepared"
} else {
"not prepared"
"prepared, no sounds (prepare again)"
};
println!("{mark} {tag} {state}");
}
+59 -4
View File
@@ -45,6 +45,41 @@ fn kind_id(prefix: &str, kind: DataKind) -> &'static str {
}
}
/// Where a switch would be: the ping has none.
fn always_on(t: &Theme) -> Div {
caps("Always on").flex_none().text_color(t.muted)
}
/// The "I'm playing" ping on the first-run question, laid out as the switches' rows.
fn ping_row(t: &Theme) -> Div {
div()
.flex()
.items_start()
.gap(px(16.))
.py(px(10.))
.border_b_1()
.border_color(t.rule)
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.gap(px(2.))
.child(
text(theme::UI)
.text_color(t.ink)
.child(telemetry::PING_NAME),
)
.child(
text(theme::CAPTION)
.text_color(t.muted)
.child(telemetry::PING),
),
)
.child(always_on(t))
}
/// The exact JSON, in mono, scrolling inside a plate.
fn json_view(view: &(String, String), t: &Theme, scroll: &Scrollbar) -> Div {
div()
@@ -87,6 +122,11 @@ impl Launcher {
Ok(consent) => self.collection.consent = consent,
Err(e) => self.collection.note = Some(format!("Could not save your choice: {e}")),
}
// The question and Settings say what the "I'm playing" ping holds, beside the switches.
if !self.settings.ping_notice_seen {
self.settings.ping_notice_seen = true;
self.settings.save();
}
self.collection.view = None;
}
@@ -148,9 +188,17 @@ impl Launcher {
.child("Help make Horizon faster and steadier?"),
)
.child(text(theme::UI).text_color(t.muted).child(
"Horizon can send a few things to its developers. Everything is off until you turn it on, and Settings can change it any time.",
"Horizon can send a few things to its developers. Each switch is off until you turn it on, and Settings can change it any time.",
))
.child(div().flex().flex_col().border_t_1().border_color(t.rule).children(rows))
.child(
div()
.flex()
.flex_col()
.border_t_1()
.border_color(t.rule)
.children(rows)
.child(ping_row(&t)),
)
.child(text(theme::CAPTION).text_color(t.muted).child(telemetry::NEVER))
.child(
div()
@@ -244,6 +292,13 @@ impl Launcher {
&t,
));
}
list = list.child(index_row(
telemetry::SWITCHES.len() + 1,
telemetry::PING_NAME,
Some(div().child(telemetry::PING).into_any_element()),
Some(always_on(&t).into_any_element()),
&t,
));
let waiting = horizon_telemetry::outbox::pending(&telemetry::home()).len();
let id = match consent.install_id.as_str() {
"" => "No install ID: nothing has been collected".to_string(),
@@ -299,7 +354,7 @@ impl Launcher {
})),
);
list = list.child(index_row(
telemetry::SWITCHES.len() + 1,
telemetry::SWITCHES.len() + 2,
"Your data",
Some(
div()
@@ -316,7 +371,7 @@ impl Launcher {
.gap(px(16.))
.child(text(theme::HEADING).text_color(t.ink).child("Data collection"))
.child(text(theme::UI).max_w(px(600.)).text_color(t.muted).child(
"Off unless you turn it on. What each switch sends is below it; the privacy page on playhorizon.dev says how long it is kept.",
"Each switch is off unless you turn it on. What it sends is below it; the privacy page on playhorizon.dev says how long it is kept.",
))
.child(list)
.child(text(theme::CAPTION).text_color(t.muted).child(telemetry::NEVER))
+87 -5
View File
@@ -160,6 +160,8 @@ struct Launcher {
args_focus: FocusHandle,
/// Opt-in data collection: the player's answer and Settings' section (`collection`).
collection: collection::Collection,
/// How many play Horizon now, while the server answers ("N playing now" on the Play page).
playing: Option<u64>,
tx: Sender<Msg>,
rx: Receiver<Msg>,
_appearance: Subscription,
@@ -277,6 +279,7 @@ impl Launcher {
friend_focus: cx.focus_handle(),
joining: None,
collection: collection::Collection::load(),
playing: None,
tx,
rx,
_appearance: appearance,
@@ -314,6 +317,7 @@ impl Launcher {
this.fetch_links();
// What the game and the launcher kept, if the player allows it and there is a server.
telemetry::send_in_background();
telemetry::watch_playing(this.tx.clone());
this
}
@@ -423,6 +427,7 @@ impl Launcher {
// The page reads is_prepared when drawn; `changed` redraws it.
Msg::Reused(_) => self.installed = backend::installed(),
Msg::Keychain(persistent) => self.keychain = persistent,
Msg::Playing(playing) => self.playing = playing,
Msg::Session(session) => {
if let Some(tag) = self.starting_client.take() {
let mut cmd = backend::client_command(&tag);
@@ -1144,6 +1149,19 @@ fn centered(text_: &str, t: &Theme) -> Div {
.child(text(theme::UI).text_color(t.muted).child(text_.to_string()))
}
/// `1234` as `1,234`.
fn thousands(n: u64) -> String {
let digits = n.to_string();
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
/// A mono line in upper case: the status above a title, the footer's links.
fn caps(s: impl AsRef<str>) -> Div {
text(theme::MICRO).child(s.as_ref().to_uppercase())
@@ -1386,6 +1404,8 @@ impl Launcher {
));
};
let prepared = backend::is_prepared(&tag);
// Prepared without sounds (#1035): Prepare again is the fix, so it stands out.
let silent = prepared && !backend::has_sounds(&tag);
let version = backend::game_version(&tag);
let hero = self.hero(&tag, &version, prepared, cx);
let running = self.client.is_some() || self.starting_client.is_some();
@@ -1427,15 +1447,13 @@ impl Launcher {
let resources_value = if self.preparing {
"Preparing…".to_string()
} else if prepared {
format!("Prepared for Minecraft {version}")
} else {
format!("Not prepared. Horizon needs the files of your own Minecraft {version}.")
backend::resources_summary(prepared, !silent, &version)
};
let mut prepare = button(
"prepare",
if prepared { "Prepare again" } else { "Prepare" },
if prepared {
if prepared && !silent {
Kind::Ghost
} else {
Kind::Secondary
@@ -1686,9 +1704,11 @@ impl Launcher {
_ => None,
};
let title = "Horizon";
// Never squeezed by the rows below: the ping notice can make it tall.
let mut hero =
div()
.flex()
.flex_none()
.flex_col()
.gap(px(14.))
.pt(px(24.))
@@ -1702,7 +1722,18 @@ impl Launcher {
.child(
caps(if prepared { "Ready" } else { "Needs preparing" })
.text_color(if prepared { t.success } else { t.muted }),
),
)
.children(self.playing.map(|n| {
div()
.flex()
.items_center()
.gap(px(10.))
.child(div().size(px(4.)).rounded_full().bg(t.rule))
.child(
caps(format!("{} playing now", thousands(n)))
.text_color(t.muted),
)
})),
)
.child(text(theme::DISPLAY).text_color(t.ink).child(title))
.child(text(theme::BODY).max_w(px(560.)).text_color(t.muted).child(
@@ -1745,6 +1776,45 @@ impl Launcher {
),
});
}
// Players who answered the first-run question before the ping existed read about it
// once here.
if !self.settings.ping_notice_seen && !self.collection.consent.needs_asking() {
hero = hero.child(
plate(&t)
.flex()
.flex_none()
.flex_col()
.gap(px(8.))
.p(px(16.))
.w_full()
.max_w(px(640.))
.child(
text(theme::UI)
.text_color(t.ink)
.child(telemetry::PING_NAME),
)
.child(
text(theme::CAPTION)
.text_color(t.muted)
.child(telemetry::PING),
)
.child(
div()
.id("ping-notice-ok")
.text_size(px(theme::UI.px))
.text_color(t.accent)
.font_weight(FontWeight::MEDIUM)
.cursor_pointer()
.hover(|s| s.opacity(0.8))
.child("Got it")
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.settings.ping_notice_seen = true;
this.settings.save();
cx.notify();
})),
),
);
}
hero
}
@@ -2933,3 +3003,15 @@ fn main() {
cx.activate(true);
});
}
#[cfg(test)]
mod tests {
#[test]
fn counts_get_thousands_separators() {
let shown: Vec<String> = [0, 7, 999, 1000, 12_300, 1_234_500]
.into_iter()
.map(super::thousands)
.collect();
assert_eq!(shown, ["0", "7", "999", "1,000", "12,300", "1,234,500"]);
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ impl Session {
"list_versions" => {
let installed = backend::installed();
let mut out = json!({
"installed": installed.iter().map(|t| json!({ "tag": t, "prepared": backend::is_prepared(t) })).collect::<Vec<_>>(),
"installed": installed.iter().map(|t| json!({ "tag": t, "prepared": backend::is_prepared(t), "sounds": backend::has_sounds(t) })).collect::<Vec<_>>(),
"selected": backend::resolve_version(None).ok(),
});
if !args
+30
View File
@@ -1,6 +1,8 @@
//! Opt-in data collection from the launcher (`horizon_telemetry`): the player's answer, what
//! would be sent, deleting it, bug reports and benchmark uploads, and sending the outbox when
//! the launcher starts and closes. The window, the CLI and the MCP server all go through here.
//! Also the live count's side here: what the "I'm playing" ping holds, in words, and reading
//! how many play now (`horizon_telemetry::live`).
use std::path::{Path, PathBuf};
use horizon_telemetry::payload::{Bug, Crash, Perf, System, Timings};
@@ -37,6 +39,33 @@ pub const SWITCHES: [(Kind, &str, &str); 4] = [
/// Never sent, whatever the switches say.
pub const NEVER: &str = "Never sent: your account name or UUID, world names, server addresses, chat, or your IP address. Each report is dated to the day, under a random install ID you can delete.";
/// The anonymous "I'm playing" ping the game sends whatever the switches say (Ludovic's
/// decision, 2026-10-22: count everyone, and tell them). Shown on the first-run question, in
/// Settings, and once on the Play page to players who answered before it existed.
pub const PING_NAME: &str = "\"I'm playing\" ping";
pub const PING: &str = "While you play, the game sends an anonymous \"I'm playing\" ping once a minute, so the launcher can show how many people play Horizon right now. It holds: a random number made when the game starts and forgotten when it closes, the Horizon version, your OS (Linux, macOS or Windows), and whether you have a world open to LAN or friends. It doesn't hold: your account, your install ID, world or server names, or anything you do in the game, and the server doesn't keep your IP address. It is sent whatever the switches say.";
/// How many play Horizon now (rounded), if the server says.
pub fn playing_now() -> Option<u64> {
let endpoint = horizon_telemetry::endpoint()?;
horizon_telemetry::live::fetch(&endpoint)
.ok()
.map(|count| count.playing)
}
/// Sends [`playing_now`] to the window as `Msg::Playing` now and every minute, on its own
/// thread. Nothing at all when there is no server.
pub fn watch_playing(tx: std::sync::mpsc::Sender<backend::Msg>) {
if horizon_telemetry::endpoint().is_none() {
return;
}
std::thread::spawn(move || {
while tx.send(backend::Msg::Playing(playing_now())).is_ok() {
std::thread::sleep(horizon_telemetry::live::INTERVAL);
}
});
}
pub fn home() -> PathBuf {
backend::game_home()
}
@@ -249,6 +278,7 @@ pub fn status_json() -> serde_json::Value {
"install_id": consent.install_id,
"waiting": outbox::pending(&home()).len(),
"server": horizon_telemetry::endpoint(),
"ping": PING,
})
}
@@ -77,6 +77,7 @@ import net.minecraft.network.protocol.game.ClientboundChunkBatchFinishedPacket;
import net.minecraft.network.protocol.game.ClientboundDisguisedChatPacket;
import net.minecraft.network.protocol.game.ClientboundLoginPacket;
import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket;
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
@@ -124,6 +125,8 @@ import net.minecraft.world.entity.player.ProfilePublicKey;
*/
public final class SecureChatClient {
private static final List<String> LINES = new ArrayList<>();
/** A command any player may run, whose reply only its sender gets (RandomCommand: value). */
private static final String BARRIER = "random value 1..2";
private static PrivateKey services;
private static String sessionUrl;
@@ -203,7 +206,7 @@ public final class SecureChatClient {
settle(all, "alice's chain is broken");
bob.send(new ServerboundChatAckPacket(50));
settle(all, "bob acknowledges too much");
for (Client c : all) c.close();
leave(all);
}
/** Online mode with enforce-secure-profile: unsigned chat is refused. */
@@ -223,13 +226,35 @@ public final class SecureChatClient {
// A key the services signed for another player.
Client mallory = Client.join(host, port, "Mallory", true, UUID.nameUUIDFromBytes("someone else".getBytes()));
settle(List.of(alice, carol, mallory), "mallory's borrowed key");
for (Client c : all) c.close();
leave(all);
mallory.close();
}
/** Lets the server answer, then records what each player received, in player order. */
/**
* The players still connected leave one at a time, each once the next was
* told the one before left (PlayerList.remove broadcasts the removal after
* the "lost connection" line): the server logs the departures in this
* order, not in the order its network threads notice the closed sockets.
*/
private static void leave(List<Client> all) throws Exception {
List<Client> clients = all.stream().filter(c -> !c.closed).toList();
for (int i = 0; i < clients.size(); i++) {
clients.get(i).close();
if (i + 1 < clients.size()) clients.get(i + 1).awaitRemoved(clients.get(i));
}
}
/**
* Lets the server answer, then records what each player received, in player order. However slow the
* server, a step ends once it handled everything sent so far: each player still connected runs a command
* whose reply comes after it, twice (a chat's broadcast is a task the chat's own task queues), and has then
* read whatever the server sent it before.
*/
private static void settle(List<Client> clients, String step) throws Exception {
Thread.sleep(1500);
for (int round = 0; round < 2; round++) {
for (Client c : clients) c.barrier();
}
LINES.add("step " + step);
for (Client c : clients) c.drain();
}
@@ -249,6 +274,10 @@ public final class SecureChatClient {
ProtocolInfo<?> inbound;
ProtocolInfo<?> outbound;
final BlockingQueue<Packet<?>> queue = new LinkedBlockingQueue<>();
/** Replies to BARRIER, kept out of the queue. */
final java.util.concurrent.Semaphore barriers = new java.util.concurrent.Semaphore(0);
/** The server ended the connection (its reader stopped). */
volatile boolean ended;
volatile boolean closed;
LocalChatSession session;
SignedMessageChain.Encoder encoder = SignedMessageChain.Encoder.UNSIGNED;
@@ -359,13 +388,21 @@ public final class SecureChatClient {
try {
while (!closed) {
Packet<?> p = read();
if (p instanceof ClientboundSystemChatPacket s
&& s.content().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t
&& t.getKey().equals("commands.random.sample.success")) {
barriers.release();
continue;
}
queue.put(p);
// Nothing follows a disconnect but the closed socket.
if (p instanceof ClientboundDisconnectPacket) return;
if (p instanceof ClientboundDisconnectPacket) break;
}
} catch (Exception e) {
if (!closed) queue.add(new ClientboundDisconnectPacket(Component.literal("connection lost: " + e)));
}
ended = true;
barriers.release();
}, name + " reader");
reader.setDaemon(true);
reader.start();
@@ -437,6 +474,21 @@ public final class SecureChatClient {
send(new ServerboundChatCommandPacket(command));
}
/** Runs BARRIER and waits for its reply (30 s at most), which isn't recorded. */
void barrier() throws Exception {
if (closed || ended) return;
barriers.drainPermits();
try {
send(new ServerboundChatCommandPacket(BARRIER));
} catch (java.io.IOException e) {
if (ended) return;
throw e;
}
if (!barriers.tryAcquire(30, TimeUnit.SECONDS) && !ended) {
LINES.add("error " + name + " got no reply to /" + BARRIER);
}
}
/** Handles and records what arrived. */
void drain() throws Exception {
Packet<?> p;
@@ -489,6 +541,18 @@ public final class SecureChatClient {
}
}
/** Waits (10 s at most) until the server removes `gone` from this player's list; records nothing else. */
void awaitRemoved(Client gone) throws Exception {
long until = System.nanoTime() + 10_000_000_000L;
while (System.nanoTime() < until) {
Packet<?> p = queue.poll(50, TimeUnit.MILLISECONDS);
if (p instanceof ClientboundPlayerInfoRemovePacket r && r.profileIds().contains(gone.id)) return;
if (p instanceof ClientboundKeepAlivePacket k) send(new ServerboundKeepAlivePacket(k.getId()));
else if (p instanceof ClientboundPingPacket ping) send(new ServerboundPongPacket(ping.getId()));
}
LINES.add("error " + name + " was never told that " + gone.name + " left");
}
/** ClientPacketListener.handlePlayerChat, then markMessageAsProcessed. */
void playerChat(ClientboundPlayerChatPacket p) {
int expected = nextChatIndex++;
@@ -411,6 +411,12 @@ public final class VanillaProtocolClient {
/** The entity mode's step, and how long it records the entities for (ms). */
private static final int ENTITIES = 1000;
private static final long ENTITY_WAIT = 20_000;
/**
* How long a step waits at most for what it waits on (a villager to trade with, the ingot, a thrown
* snowball, the pearl's teleport and hurt): a busy machine can hold a server's ticks back for seconds,
* so the steps wait for those, not for a fixed time.
*/
private static final long PATIENCE = 30_000;
static int moveEntityId(net.minecraft.network.protocol.game.ClientboundMoveEntityPacket p) {
return (int) field(p, net.minecraft.network.protocol.game.ClientboundMoveEntityPacket.class, "entityId");
@@ -464,6 +470,9 @@ public final class VanillaProtocolClient {
int furnaceState = 0;
// Snowballs the server spawned since the last right click.
int snowballs = 0;
// When the first of them came, and when the furnace's ingot did.
long snowballAt = 0;
long smeltedAt = 0;
// The player's own id, and what the ender pearl it throws does to it.
int playerId = -1;
boolean pearl = false;
@@ -550,6 +559,7 @@ public final class VanillaProtocolClient {
&& furnaceWindow > 0 && p.getContainerId() == furnaceWindow) {
furnaceState = p.getStateId();
record("play-furnace", "slot " + p.getSlot() + " " + p.getItem());
if (p.getSlot() == 2 && !p.getItem().isEmpty() && smeltedAt == 0) smeltedAt = now;
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundContainerSetDataPacket p
&& furnaceWindow > 0 && p.getContainerId() == furnaceWindow && (p.getId() == 1 || p.getId() == 3)) {
// Lit total and cooking total; the time left and the cooking timer follow the ticks.
@@ -580,6 +590,7 @@ public final class VanillaProtocolClient {
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket p
&& p.getType() == net.minecraft.world.entity.EntityTypes.SNOWBALL) {
snowballs++;
if (snowballAt == 0) snowballAt = now;
// Projectile.getAddEntityPacket: its owner's id.
record("play-use", "snowball owner is the player: " + (p.getData() == playerId));
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket p
@@ -666,8 +677,8 @@ public final class VanillaProtocolClient {
send(new ServerboundChatCommandPacket(REPLIES[step - 2]));
step++;
stepAt = now;
} else if (step == 2 + REPLIES.length && now - stepAt > 1500) {
// Right-click the villager: the trading screen and its offers.
} else if (step == 2 + REPLIES.length && now - stepAt > 1500 && (villager >= 0 || now - stepAt > PATIENCE)) {
// Right-click the villager (once the server sent it): the trading screen and its offers.
record("play-trade", "villager " + (villager >= 0));
if (villager >= 0) {
send(new net.minecraft.network.protocol.game.ServerboundInteractPacket(villager,
@@ -715,8 +726,9 @@ public final class VanillaProtocolClient {
net.minecraft.core.Direction.NORTH, furnace, false), 1));
step++;
stepAt = now;
} else if (step >= 8 + REPLIES.length && step <= 10 + REPLIES.length && furnaceWindow > 0
&& now - stepAt > (step == 10 + REPLIES.length ? 12_000 : 1500)) {
} else if (step >= 8 + REPLIES.length && step <= 10 + REPLIES.length && furnaceWindow > 0 && now - stepAt > 1500
// The ingot out once the furnace made it (200 ticks), and its tick's other changes came.
&& (step < 10 + REPLIES.length || smeltedAt > 0 && now - smeltedAt > 1000 || now - stepAt > 10_000 + PATIENCE)) {
// Shift-click the ore (hotbar 0) into the input, the coal (hotbar 1) into the
// fuel slot, and the smelted ingot out of the result; nothing claimed, so the
// server sends every slot the click changed.
@@ -743,9 +755,10 @@ public final class VanillaProtocolClient {
useOn(respawnAt, net.minecraft.world.InteractionHand.MAIN_HAND, 2);
use(position, net.minecraft.world.InteractionHand.MAIN_HAND, 3);
snowballs = 0;
snowballAt = 0;
step++;
stepAt = now;
} else if (step == 14 + REPLIES.length && now - stepAt > 2000) {
} else if (step == 14 + REPLIES.length && thrown(snowballAt, stepAt, now)) {
record("play-use", "main hand on a block: " + snowballs + " snowball(s)");
send(new ServerboundChatCommandPacket("item replace entity @s weapon.mainhand with minecraft:air"));
send(new ServerboundChatCommandPacket("item replace entity @s weapon.offhand with minecraft:snowball 16"));
@@ -757,9 +770,10 @@ public final class VanillaProtocolClient {
useOn(respawnAt, net.minecraft.world.InteractionHand.OFF_HAND, 5);
use(position, net.minecraft.world.InteractionHand.OFF_HAND, 6);
snowballs = 0;
snowballAt = 0;
step++;
stepAt = now;
} else if (step == 16 + REPLIES.length && now - stepAt > 2000) {
} else if (step == 16 + REPLIES.length && thrown(snowballAt, stepAt, now)) {
record("play-use", "off hand on a block: " + snowballs + " snowball(s)");
// An ender pearl thrown at its feet (no endermite: ThrownEnderpearl spawns one 5% of the time).
send(new ServerboundChatCommandPacket("gamerule spawn_monsters false"));
@@ -773,7 +787,8 @@ public final class VanillaProtocolClient {
position.yRot(), 90.0F));
step++;
stepAt = now;
} else if (step == 18 + REPLIES.length && now - stepAt > 2500) {
} else if (step == 18 + REPLIES.length && now - stepAt > 2500
&& (pearlTeleports > 0 && pearlHealth >= 0 || now - stepAt > PATIENCE)) {
// ThrownEnderpearl.onHit: the thrower is teleported and takes 5 damage.
record("play-pearl", "teleported " + pearlTeleports + " time(s), health " + pearlHealth);
return;
@@ -786,6 +801,14 @@ public final class VanillaProtocolClient {
record("error", "timed out in play, step " + step + ", chunks " + chunks.size());
}
/**
* A right click's snowballs are counted: once the first came and a second could have come with it
* (the server spawns each in the tick it handles the click), or once the server had all the time.
*/
private static boolean thrown(long first, long clicked, long now) {
return now - clicked > 2000 && (first > 0 && now - first > 1000 || now - clicked > PATIENCE);
}
/** The death screen's Respawn button: true, the respawn being under way. */
private boolean respawn() throws Exception {
send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket(
@@ -821,10 +844,12 @@ public final class VanillaProtocolClient {
*/
private String respawnLine(Received r) {
String t = r.type;
// Entities moving and the dead player's health land in the window between the death
// and the respawn as each server's ticks fall: left out.
// Entities moving, blocks the world's ticks change (a lava fall flows again once the game
// runs) and the dead player's health land in the window between the death and the respawn
// as each server's ticks fall: left out.
if (t.startsWith("minecraft:move_entity") || t.equals("minecraft:rotate_head") || t.equals("minecraft:set_entity_motion")
|| t.equals("minecraft:set_entity_data") || t.equals("minecraft:entity_position_sync") || t.equals("minecraft:set_health")) {
|| t.equals("minecraft:set_entity_data") || t.equals("minecraft:entity_position_sync") || t.equals("minecraft:set_health")
|| t.equals("minecraft:block_update") || t.equals("minecraft:section_blocks_update")) {
return null;
}
if (t.equals("minecraft:keep_alive") || t.equals("minecraft:ping") || t.equals("minecraft:level_chunk_with_light")
+44 -13
View File
@@ -195,13 +195,34 @@ def run_client(binary, build, port, label, token, discovery):
PLAYER_LINE = re.compile(r'^\[\d\d:\d\d:\d\d\] \[([^\]]+)\]: (.*(?:Tester|Username|Disconnecting|verify username).*)$')
def player_lines(lines):
def player_lines(lines, starts=()):
"""The lines about the player, login by login (from each index in `starts` to the next), with two races of
vanilla's own taken out:
- the server's thread and the login's authenticator each log in their own order, but which of the two logs
first is a race (the authenticator disconnects a player, then logs why, while the server thread may already
log the lost connection): each login's lines are grouped by thread;
- a player the server disconnects ("Disconnecting ...: reason") closes the connection once it reads why, and
when that comes before the server closes it, the lost connection is logged as "Disconnected": the kick's
reason stands for both."""
def thread(line):
# `[User Authenticator #2/INFO]`: the thread, without the level; the login's authenticator first.
name = (m := PLAYER_LINE.match(line)) and m.group(1).rsplit('/', 1)[0] or ''
return name == 'Server thread', name
bounds = [0, *starts, len(lines)]
out = []
for line in lines:
if m := PLAYER_LINE.match(line):
text = re.sub(r'/127\.0\.0\.1:\d+', '/127.0.0.1:_', m.group(2))
text = re.sub(r'entity id \d+ at \(.*\)', 'entity id _ at _', text)
out.append(f'[{m.group(1)}] {text}')
for a, b in zip(bounds, bounds[1:]):
login = []
for line in sorted(lines[a:b], key=thread):
if m := PLAYER_LINE.match(line):
text = re.sub(r'/127\.0\.0\.1:\d+', '/127.0.0.1:_', m.group(2))
text = re.sub(r'entity id \d+ at \(.*\)', 'entity id _ at _', text)
login.append(f'[{m.group(1)}] {text}')
kicks = {l.rsplit('): ', 1)[1] for l in login if '] Disconnecting ' in l}
if len(kicks) == 1:
login = [l.removesuffix('Disconnected') + next(iter(kicks)) if l.endswith(') lost connection: Disconnected')
else l for l in login]
out += login
return out
@@ -228,16 +249,26 @@ def run_side(label, command, binary, build, services, discovery):
os.environ.update(HORIZON_NO_AUDIO='1', HORIZON_API_DISCOVERY=discovery)
server = Server(command, directory, f'{label}-online')
checks = []
starts = []
def check(name, actual, expected):
checks.append(f'{name}: ' + ('ok' if actual == expected else f'expected {expected!r}, got {actual!r}'))
def login(name, token):
"""One client run, over once the server logged the end of its connection: the server notices the
closed connection at its next tick, and the next run's lines must not come before that one."""
start = len(server.lines)
starts.append(start)
got = run_client(binary, build, port, f'{label}-{name}', token, discovery)
wait_after(server, start, r'lost connection: ')
return got
try:
server.wait_for(r'Done \(', timeout=600)
services.take_log()
# 1. Signed in: the full encrypted handshake, then play.
got = run_client(binary, build, port, f'{label}-signed-in', GOOD, discovery)
got = login('signed-in', GOOD)
server.wait_for(r'Tester left the game', timeout=30)
log = services.take_log()
joins = [e for e in log if e[0] == 'join']
@@ -261,20 +292,20 @@ def run_side(label, command, binary, build, services, discovery):
# 2. A token the session service rejects: vanilla's message, no key sent.
mark = len(server.lines)
got = run_client(binary, build, port, f'{label}-rejected', BAD, discovery)
got = login('rejected', BAD)
check('rejected token: client message', got['error'],
'disconnected: Failed to log in: Invalid session (Try restarting your game and the launcher)')
check('rejected token: no verification', [e[0] for e in services.take_log()], ['join'])
# 3. The session service forgets the join: the server refuses the player.
got = run_client(binary, build, port, f'{label}-forgotten', FORGET, discovery)
got = login('forgotten', FORGET)
server.wait_for(r'Failed to verify username', timeout=30)
check('forgotten join: client message', got['error'], 'disconnected: Failed to verify username!')
check('forgotten join: server verified and refused',
[(e[0], e[3]) for e in services.take_log()], [('join', True), ('verify', False)])
# 4. No account: refused by the client before anything is sent to the services.
got = run_client(binary, build, port, f'{label}-no-account', None, discovery)
got = login('no-account', None)
check('no account: client message', got['error'] or '', 'disconnected: This server is in online mode. '
'Sign in with your Microsoft account in the Horizon launcher to join it.')
check('no account: no service calls', services.take_log(), [])
@@ -283,7 +314,7 @@ def run_side(label, command, binary, build, services, discovery):
# 5. Verified, but not on the whitelist.
server.console('whitelist on')
server.wait_for(r'Whitelist is now turned on', timeout=30)
got = run_client(binary, build, port, f'{label}-not-whitelisted', GOOD, discovery)
got = login('not-whitelisted', GOOD)
server.wait_for(r'Disconnecting Tester', timeout=30)
check('not whitelisted: client message', got['error'], 'disconnected: You are not white-listed on this server!')
check('not whitelisted: verified first', [(e[0], e[3]) for e in services.take_log()],
@@ -295,7 +326,7 @@ def run_side(label, command, binary, build, services, discovery):
server.console('whitelist add tester')
wait_after(server, mark, r'Added Tester to the whitelist')
check('whitelist add: known player, no lookup', services.take_log(), [])
got = run_client(binary, build, port, f'{label}-whitelisted', GOOD, discovery)
got = login('whitelisted', GOOD)
wait_after(server, mark, r'Tester left the game')
check('whitelisted: connected throughout', got['error'], None)
services.take_log()
@@ -309,7 +340,7 @@ def run_side(label, command, binary, build, services, discovery):
time.sleep(1)
finally:
server.stop()
return checks, player_lines(server.lines)
return checks, player_lines(server.lines, starts)
def main():
+9 -1
View File
@@ -236,10 +236,18 @@ def run_side(label, scenario, command, directory, client):
time.sleep(2)
out = directory.parent / f'{label}-{scenario}.txt'
client(port, scenario, out)
# Carol leaves last: stopping before the server saw her go would log another reason.
server.wait_for(r'Carol lost connection', timeout=30)
transcript = [f'status enforcesSecureChat {enforces}'] + out.read_text(encoding='utf-8').splitlines()
finally:
server.stop()
return transcript, chat_lines(server.lines)
# A player the server disconnects (the transcript has the reason it sent, compared there) is logged as
# "lost connection: <that reason>", or as "Disconnected" when vanilla's network thread closed the connection
# before its disconnect(reason) ran: a race in vanilla, so the log's reason is left out for them.
kicked = {m.group(1) for line in transcript if (m := re.match(r'(\w+) disconnected ', line))}
log = [re.sub(r'^(\[\w+\] (\w+) lost connection: ).*', lambda m: m.group(1) + '(the reason sent)'
if m.group(2) in kicked else m.group(0), line) for line in chat_lines(server.lines)]
return transcript, log
def main():
+15 -22
View File
@@ -17,13 +17,13 @@ stops; Horizon's (`horizon-client --dedicated --nogui`) creates its own from
the same server.properties and stops; the `spawn` position of both
level.dat files is compared.
Vanilla is not deterministic everywhere: trees of neighbouring chunks
overlap on chunk corners, and which one is placed last depends on the order
the server's worker threads decorate the chunks. A spawn column on such a
corner can then stand on different leaves from one vanilla run to the next.
When the spawns differ, vanilla creates the world up to RETRIES more times;
Horizon's spawn passes if one of vanilla's runs gives it too, and the seed is
reported as varying in vanilla.
Trees of neighbouring chunks overlap on chunk corners, and which one is
placed first depends on the order the chunks are decorated. With several
worker threads vanilla's order changes from run to run (seed
-2080727421395798566 spawned at Y 64, 66 or 72), so its server runs with one
(`-Dmax.bg.threads`, Util.maxAllowedExecutorThreads): the chunks are then
decorated in its scheduler's order, the one Horizon follows
(decoration_order::vanilla_order), and every run gives the same spawn.
Seeds: the player report's seed (1891623539983467248: Horizon spawned at
-48 -48, vanilla at 152 145), the chunk generation harness's seeds and
@@ -54,7 +54,6 @@ except ImportError:
NAME = 'world-spawn-parity'
REPORT = 1891623539983467248
RETRIES = 3
SEEDS = [REPORT, 12345, -7046029254386353131, 987654321987, 4242, -1, 0x5DEECE66D, 20260607, 777, 0]
RESOURCES = lab.RESOURCES
@@ -132,7 +131,8 @@ def main():
oh.run_logged(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], build,
'compile-rust', timeout=3600)
binary = target_dir() / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
vanilla_server = ['java', '-Xmx2g', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
# One worker thread: the chunks decorated in a fixed order (see above).
vanilla_server = ['java', '-Xmx2g', '-Dmax.bg.threads=1', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
horizon_server = [binary, '--resources', RESOURCES, '--dedicated', '--nogui']
os.environ['HORIZON_NO_AUDIO'] = '1'
@@ -140,27 +140,20 @@ def main():
t0 = time.time()
a = world_spawn(vanilla_server, build / f'vanilla-{seed}', seed, f'vanilla-{seed}')
b = world_spawn(horizon_server, build / f'horizon-{seed}', seed, f'horizon-{seed}')
runs = [a]
while b not in runs and len(runs) <= RETRIES:
runs.append(world_spawn(vanilla_server, build / f'vanilla-{seed}-{len(runs)}', seed, f'vanilla-{seed}-{len(runs)}'))
return seed, runs, b, time.time() - t0
return seed, a, b, time.time() - t0
spawn_diffs, varying = [], []
spawn_diffs = []
with ThreadPoolExecutor(args.jobs) as pool:
for seed, runs, b, took in pool.map(one, all_seeds):
ok = b in runs
for seed, a, b, took in pool.map(one, all_seeds):
ok = a == b
if not ok:
spawn_diffs.append(seed)
elif len(set(runs)) > 1:
varying.append(seed)
seen = ', '.join(' '.join(map(str, r)) for r in dict.fromkeys(runs))
print(f'{"ok " if ok else "DIFF"} {seed}: vanilla {seen} / horizon {b[0]} {b[1]} {b[2]} '
print(f'{"ok " if ok else "DIFF"} {seed}: vanilla {a[0]} {a[1]} {a[2]} / horizon {b[0]} {b[1]} {b[2]} '
f'(origin chunk {vanilla[seed][2]} {vanilla[seed][3]}; {took:.0f}s)', flush=True)
if ok:
for path in build.glob(f'*-{seed}*'):
shutil.rmtree(path, ignore_errors=True)
print(f'World spawns: {len(all_seeds) - len(spawn_diffs)}/{len(all_seeds)} seeds match in {time.time() - started:.0f}s'
+ (f'; vanilla itself gave several spawns for {len(varying)}: {varying}' if varying else ''))
print(f'World spawns: {len(all_seeds) - len(spawn_diffs)}/{len(all_seeds)} seeds match in {time.time() - started:.0f}s')
if origin_diffs or spawn_diffs:
sys.exit(1)
+1 -1
View File
@@ -56,7 +56,7 @@ In part. Horizon runs the full-screen effects of Java shader packs, not their ge
## Does Horizon collect data?
Only if you say yes, and each kind has its own switch. Everything is off by default. See [Privacy](/privacy).
Only if you say yes, and each kind has its own switch. Everything is off by default. The one thing sent without asking is an anonymous "I'm playing" ping, for the count of how many play right now: it holds a random number made each time the game starts, the version, your OS and whether a world is open to LAN, and nothing about you. See [Privacy](/privacy).
## How often does it update?
+1 -1
View File
@@ -52,4 +52,4 @@ A Horizon bug is any place where it differs from vanilla 26.3, so "vanilla does
## Data the launcher can send
The launcher can send performance statistics, crash reports, bug reports and benchmarks, but only the kinds you allow, and everything is off until you say yes. See [Privacy](/privacy) for what is sent, how long it is kept and how to delete it.
The launcher can send performance statistics, crash reports, bug reports and benchmarks, but only the kinds you allow, and everything is off until you say yes. The game also sends an anonymous "I'm playing" ping while it runs, for the live player count. See [Privacy](/privacy) for what is sent, how long it is kept and how to delete it.
+9 -4
View File
@@ -17,8 +17,8 @@
<main id="main" class="section">
<div class="container">
<p class="kicker"><span>Privacy</span>Data collection</p>
<h1 class="display">Nothing leaves without your yes.</h1>
<p class="lede">The Horizon launcher can send a few things that help make the game faster and steadier. It asks first, each kind has its own switch, and every switch is off until you turn it on.</p>
<h1 class="display">Nothing about you leaves without your yes.</h1>
<p class="lede">The Horizon launcher can send a few things that help make the game faster and steadier. It asks first, each kind has its own switch, and every switch is off until you turn it on. The only thing sent without asking is an anonymous "I'm playing" ping, which says nothing about you.</p>
<div class="prose">
<h2>What can be sent</h2>
<p>Only the kinds you allow, from the launcher's first-run question or Settings → Data collection:</p>
@@ -32,11 +32,16 @@
<p>Never sent: your Microsoft or Minecraft account name or UUID, world names, server or LAN addresses, chat, and your user name or home folder in file paths. Before anything is kept, those are replaced with placeholders, along with e-mail and IP addresses. The server does not store the IP address requests come from.</p>
<p>The launcher shows exactly what would be sent: Settings → Data collection → View what would be sent, or <code>horizon-launcher data show</code>. Every field is listed in <a href="https://github.com/ludovic111/horizon/blob/main/docs/telemetry.md">docs/telemetry.md</a>.</p>
<h2>The "I'm playing" ping</h2>
<p>While you play, the game sends an anonymous "I'm playing" ping once a minute, so the launcher can show how many people play Horizon right now. It is sent whatever the switches say, and the launcher says so next to them.</p>
<p>It holds: a random number made when the game starts and forgotten when it closes, the Horizon version, your operating system (Linux, macOS or Windows), and whether you have a world open to LAN or to friends. It doesn't hold: your account, your install ID, world or server names, your hardware or settings, or anything you do in the game. The random number changes every time you start the game, so two of your sessions can't be linked to each other or to you.</p>
<p>The server keeps the pings in memory only, never on disk, and not with the address they came from (that is used only to stop abuse, as below). A session counts while it pinged in the last two minutes and is then forgotten. What it shows is the number playing now, per version and with a world open, rounded.</p>
<h2>Why</h2>
<p>To find what is slow or crashes on which machines, and fix it. Benchmark uploads also show other players how Horizon runs on hardware like theirs. Nothing is sold, shared for advertising, or used to identify you.</p>
<p>To find what is slow or crashes on which machines, and fix it. The ping shows players, and Horizon, how many people play right now. Benchmark uploads also show other players how Horizon runs on hardware like theirs. Nothing is sold, shared for advertising, or used to identify you.</p>
<h2>On what basis</h2>
<p>Your consent (Article 6(1)(a) of the EU's General Data Protection Regulation). It is given kind by kind, recorded on your computer with the version of the question you answered and when, and withdrawn at any time with the same switches. Withdrawing deletes what was waiting to be sent. When what is collected changes, the launcher asks again, and nothing new is sent until you answer.</p>
<p>For the switches: your consent (Article 6(1)(a) of the EU's General Data Protection Regulation). It is given kind by kind, recorded on your computer with the version of the question you answered and when, and withdrawn at any time with the same switches. Withdrawing deletes what was waiting to be sent. When what is collected changes, the launcher asks again, and nothing new is sent until you answer. The ping holds nothing that identifies you; the address it comes from is used only to limit how often one address may send, kept in memory for an hour at most and never stored.</p>
<h2>Where and how long</h2>
<p>Items wait on your computer (<code>telemetry/</code> in the game folder) and are sent compressed when the launcher starts or closes, to Horizon's own server at <a href="https://railway.com">Railway</a>. The server keeps them for <strong>180 days</strong>, then deletes them. What stays after that are anonymous totals per day, Horizon version, operating system, GPU maker and render distance (for example "120 sessions on macOS with Apple GPUs at render distance 12"). They hold no install ID, and groups too small to be anonymous are never shown.</p>