Compare commits

...
Author SHA1 Message Date
Horizon agent 9c7dbc2e09 horizon-report: overhead counts the tick's own spans; other worker jobs counted, not timed
On a smoke server (2 ms ticks) the overhead line charged the chunk pump's spans of the
wait between ticks (1,788 a tick) to MSPT. Only the spans the tick records count now,
and a dedicated server records spans anyway for its slow-tick line (HORIZON_SPIKES,
on by default), so they are listed apart and left out of the report's own cost.
The 'other' worker jobs ran at 278k/s for 0.0 ms each: two thread-CPU clock reads
each cost more than the job; they are counted only. A sum of no players' rates no
longer reads -0.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit eb55d13be3)
2026-11-05 21:27:08 +01:00
Horizon agentandClaude Opus 5.5 fcfe5862ac horizon-report: is_multiple_of (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 20:02:47 +01:00
Horizon agent b247769b90 horizon-report: loose entities of every level; rustfmt
The items, orbs, projectiles, minecarts and boats were counted for the level in place
only (the End, on the SMP, when the report ends), so tick_boats' 3 ms had no count
beside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit d216a5477d)
2026-11-05 20:00:41 +01:00
Horizon agent 25490df982 horizon-report: spans inside tick_lan and tick_sculk_entities
On the official SMP, 12.6 of tick_lan's 14.7 ms a tick and all of tick_sculk_entities'
6.4 ms had no span below them. Name the steps: guest ticks, entity states, the hub's
level pass (world sync, chunks, block changes, containers, entity tracking, player
state), the end of the tick and the host events; sculk's prepare, wardens, step-on and
entity game events. Spans cost nothing while off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit ef4463a476)
2026-11-05 19:57:07 +01:00
Horizon agent 093d31721e horizon-report: per-mob-type cost, level spans, chunk workers, network, overhead, stop/status
The official SMP is mob-bound (5,400 mobs, 73 ms a tick at 60 players) but the report
only said tick_creature_entities took the time, not which mobs. Format 2 adds, without
a restart and at a measured cost:
- mob_costs: one tick in four times every mob's tick and sums it by dimension and type
  (ms a tick, mobs ticked, us a mob);
- level_* spans: each dimension's share of the server tick;
- chunk_workers: streaming::job_stats while recording (load/generate/decorate/light...);
- network: wire bytes in/out per player and in all (connection counters, no new
  per-packet cost);
- overhead: spans and mob samples a tick at their price measured on the machine;
- horizon-report stop / status.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit ab9929b5ab)
2026-11-05 19:51:29 +01:00
Ludovic Marie 52882c1e8c Merge pull request #9212 from horizon-rust/fix/8669-useitemon-freeze
🤖 Agent: The freeze came from map searches opened from chests, which built full jigsaw starts and scanned every chunk of each saved region for each candidate. Same answers on main and this branch (24 targets × 5 points × 4 runs); on a pregenerated world the bamboo-camp map search drops from 2.1–3.6 s to 0.04–0.07 s of server-thread CPU, and main hit the 60 s watchdog in 4 of 9 runs. fmt, both clippies and the touched crates' tests pass, plus 10 harnesses incl. server_world_gen (/locate against vanilla); dedicated_server's vanilla oracle crashed on the loaded desktop, so CI runs it.

(cherry picked from commit 6d43a3593b)
2026-11-04 21:44:39 +01:00
Ludovic Marie 07d96de7fa Server: moderation commands in operators' command tree, /mute [duration], /tempbanlist
/tempban, /mute, /tempmute, /mutelist and /unmute worked on dedicated servers but were left out of the
command tree, so operators' clients showed them as unknown commands with no completions, and the
official server's operators believed only permanent bans existed. They now reach the trees of
operators of level 3 (as a native mod's commands do), where the game runs them: the mute commands
when the server filters chat, the ban ones on a dedicated server.

/mute <player> [duration] [reason] takes a duration after the name (/tempmute stays), and
/tempbanlist lists the bans that end by themselves, soonest first, with who banned, the time left,
the end and the reason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 4be5ea8a3a)
2026-11-04 20:48:47 +01:00
voidsip c6a2010f4a Keep moderation replies translatable for each client
Formatting mute and temporary-ban replies as English discarded their translation structure before clients received them. Preserve nested components and duration units, with readable fallbacks for vanilla clients that do not have Horizon's language bundles.

Fixes #8255

Co-Authored-By: Codex <[email protected]>
(cherry picked from commit f5a937cb4c)
2026-11-04 20:48:34 +01:00
31 changed files with 1895 additions and 198 deletions

No files matched your search

+28 -7
View File
@@ -489,15 +489,36 @@ pub fn parse_duration(text: &str) -> Option<i64> {
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
(60 * MINUTE, DurationUnit::Hour)
} else {
(up(24 * 60 * MINUTE), "day")
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
+16
View File
@@ -327,3 +327,19 @@ fn an_allow_line_without_letters_allows_nothing() {
assert!(f.matches("nigger"), "{line}");
}
}
#[test]
fn duration_parts_preserve_rounding_without_overflow() {
for (millis, expected) in [
(1, (1, DurationUnit::Minute)),
(60_000, (1, DurationUnit::Minute)),
(61_000, (2, DurationUnit::Minute)),
(3_600_000, (60, DurationUnit::Minute)),
(3_600_001, (2, DurationUnit::Hour)),
(172_800_000, (48, DurationUnit::Hour)),
(172_800_001, (3, DurationUnit::Day)),
(i64::MAX, (106_751_991_168, DurationUnit::Day)),
] {
assert_eq!(duration_parts(millis), expected);
}
}
+74 -17
View File
@@ -68,27 +68,48 @@ fn cpu_if_enabled() -> Option<Duration> {
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
let _span = guard(name);
f()
}
/// A span that ends where it is dropped: [`time`] for a step with several ways out (early
/// returns). Nothing is read while spans are off.
#[must_use = "the span ends when the guard is dropped"]
pub struct Guard {
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
_heap: Option<horizon_threads::heap_census::Tag>,
}
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
#[inline]
pub fn guard(name: &'static str) -> Guard {
let _heap = horizon_threads::heap_tag(name);
if !recording_here() {
return f();
return Guard { open: None, _heap };
}
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
let (started, cpu) = (Instant::now(), cpu_if_enabled());
let out = f();
let ms = started.elapsed().as_secs_f32() * 1000.0;
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
});
out
Guard {
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
_heap,
}
}
impl Drop for Guard {
fn drop(&mut self) {
let Some((name, started, cpu, depth)) = self.open.take() else {
return;
};
let ms = started.elapsed().as_secs_f32() * 1000.0;
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
});
}
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
@@ -551,6 +572,42 @@ mod tests {
.unwrap();
}
/// A guarded span ends on every way out of its step, an early return's too, and holds the
/// spans that ran inside it until then; the next span outside it is back at its depth.
#[test]
fn a_guarded_span_ends_where_its_step_returns() {
fn step(early: bool) -> u32 {
let _span = super::guard("use_block");
super::time("open_loot", || ());
if early {
return 1;
}
super::time("open_maps", || ());
2
}
std::thread::spawn(|| {
super::record_here(true);
assert_eq!(step(true), 1);
assert_eq!(step(false), 2);
super::time("use_item", || ());
let raw = super::take_raw();
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
assert_eq!(
seen,
[
("open_loot", 1),
("use_block", 0),
("open_loot", 1),
("open_maps", 1),
("use_block", 0),
("use_item", 0)
]
);
})
.join()
.unwrap();
}
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
/// at least half of it; repeated spans (several ticks a frame) add up.
#[test]
+15 -3
View File
@@ -824,7 +824,14 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
Job::Mesh(..) => 5,
_ => 6,
};
(kind, crate::spans::thread_cpu().unwrap_or_default())
// The other jobs (saves, light views, freed meshes) are many and short: counted, not
// timed, so the clock reads don't cost more than they do.
let cpu = if kind == 6 {
std::time::Duration::ZERO
} else {
crate::spans::thread_cpu().unwrap_or_default()
};
(kind, cpu)
});
let _tag = horizon_threads::heap_tag(match &job.job {
Job::Generate(..) => "stream.generate",
@@ -853,8 +860,13 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
} else {
kind
};
let cpu = crate::spans::thread_cpu().unwrap_or_default();
job_stats::add(kind, cpu.saturating_sub(started).as_nanos() as u64);
let nanos = if kind == 6 {
0
} else {
let cpu = crate::spans::thread_cpu().unwrap_or_default();
cpu.saturating_sub(started).as_nanos() as u64
};
job_stats::add(kind, nanos);
}
if let Some(done) = done {
self.shared.done.send(done);
@@ -346,5 +346,34 @@
"horizon.extras.cosmetics_auras.tooltip": "Auren und Spuren, die andere Spieler tragen. Standardmäßig aus.",
"horizon.extras.cosmetics.shown": "Angezeigt",
"horizon.extras.cosmetics.hidden": "Ausgeblendet",
"horizon.extras.cosmetics.name_tags": "Nur Namensschilder"
"horizon.extras.cosmetics.name_tags": "Nur Namensschilder",
"horizon.moderation.duration.minute": "%s Minute",
"horizon.moderation.duration.minutes": "%s Minuten",
"horizon.moderation.duration.hour": "%s Stunde",
"horizon.moderation.duration.hours": "%s Stunden",
"horizon.moderation.duration.day": "%s Tag",
"horizon.moderation.duration.days": "%s Tage",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "Keine Spieler stummgeschaltet",
"horizon.moderation.mutelist.count": "Stummgeschaltete Spieler: %s",
"horizon.moderation.chat_filter": "dem Chatfilter",
"horizon.moderation.until_unmuted": "bis zur Aufhebung der Stummschaltung",
"horizon.moderation.remaining": "für weitere %s",
"horizon.moderation.mutelist.entry": "%1$s wurde von %2$s %3$s stummgeschaltet",
"horizon.moderation.invalid_duration": "%s ist keine Zeitdauer: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <Spieler> [Dauer] [Grund]",
"horizon.moderation.usage.tempmute": "/tempmute <Spieler> <Dauer> [Grund]",
"horizon.moderation.usage.unmute": "/unmute <Spieler>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "für %s",
"horizon.moderation.muted": "%1$s wurde %2$s stummgeschaltet",
"horizon.moderation.unmuted": "%s ist nicht mehr stummgeschaltet",
"horizon.moderation.not_muted": "%s ist nicht stummgeschaltet",
"horizon.tempban.usage": "/tempban <Spieler> <Dauer> [Grund]",
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
}
@@ -522,5 +522,34 @@
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Off by default.",
"horizon.extras.cosmetics.shown": "Shown",
"horizon.extras.cosmetics.hidden": "Hidden",
"horizon.extras.cosmetics.name_tags": "Name tags only"
"horizon.extras.cosmetics.name_tags": "Name tags only",
"horizon.moderation.duration.minute": "%s minute",
"horizon.moderation.duration.minutes": "%s minutes",
"horizon.moderation.duration.hour": "%s hour",
"horizon.moderation.duration.hours": "%s hours",
"horizon.moderation.duration.day": "%s day",
"horizon.moderation.duration.days": "%s days",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "There are no mutes",
"horizon.moderation.mutelist.count": "There are %s mute(s):",
"horizon.moderation.chat_filter": "the chat filter",
"horizon.moderation.until_unmuted": "until unmuted",
"horizon.moderation.remaining": "for another %s",
"horizon.moderation.mutelist.entry": "%1$s was muted by %2$s %3$s",
"horizon.moderation.invalid_duration": "%s isn't a duration: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "for %s",
"horizon.moderation.muted": "Muted %1$s %2$s",
"horizon.moderation.unmuted": "Unmuted %s",
"horizon.moderation.not_muted": "%s isn't muted",
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
"horizon.tempban.invalid_duration": "%s isn't a duration: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporary bans need a dedicated server",
"horizon.tempban.success": "%1$s for %2$s, until %3$s",
"horizon.tempban.list.empty": "There are no temporary bans",
"horizon.tempban.list.count": "There are %s temporary ban(s):",
"horizon.tempban.list.entry": "%1$s was banned by %2$s %3$s (until %4$s)"
}
@@ -228,5 +228,34 @@
"How frames reach the screen, and the performance overlay.": "Cómo llegan los fotogramas a la pantalla, y la superposición de rendimiento.",
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
"Results for \"%s\"": "Resultados de \"%s\"",
"No settings match.": "Ningún ajuste coincide."
"No settings match.": "Ningún ajuste coincide.",
"horizon.moderation.duration.minute": "%s minuto",
"horizon.moderation.duration.minutes": "%s minutos",
"horizon.moderation.duration.hour": "%s hora",
"horizon.moderation.duration.hours": "%s horas",
"horizon.moderation.duration.day": "%s día",
"horizon.moderation.duration.days": "%s días",
"horizon.moderation.reason": "%1$s: %2$s",
"horizon.moderation.mutelist.empty": "No hay jugadores silenciados",
"horizon.moderation.mutelist.count": "Jugadores silenciados: %s",
"horizon.moderation.chat_filter": "el filtro del chat",
"horizon.moderation.until_unmuted": "hasta que se retire el silencio",
"horizon.moderation.remaining": "durante %s más",
"horizon.moderation.mutelist.entry": "%1$s fue silenciado por %2$s %3$s",
"horizon.moderation.invalid_duration": "%s no es una duración: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <jugador> [duración] [motivo]",
"horizon.moderation.usage.tempmute": "/tempmute <jugador> <duración> [motivo]",
"horizon.moderation.usage.unmute": "/unmute <jugador>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "durante %s",
"horizon.moderation.muted": "Se ha silenciado a %1$s %2$s",
"horizon.moderation.unmuted": "%s ya no está silenciado",
"horizon.moderation.not_muted": "%s no está silenciado",
"horizon.tempban.usage": "/tempban <jugador> <duración> [motivo]",
"horizon.tempban.invalid_duration": "%s no es una duración: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Los bloqueos temporales necesitan un servidor dedicado",
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s",
"horizon.tempban.list.empty": "No hay baneos temporales",
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)"
}
@@ -443,5 +443,34 @@
"horizon.repro.bundle": "Archive de reproduction : %s",
"horizon.repro.error": "Reproduction : %s",
"horizon.repro.no_recording": "Il n'y a pas encore d'enregistrement à exporter.",
"horizon.repro.replay_started": "Lecture démarrée : %s"
"horizon.repro.replay_started": "Lecture démarrée : %s",
"horizon.moderation.duration.minute": "%s minute",
"horizon.moderation.duration.minutes": "%s minutes",
"horizon.moderation.duration.hour": "%s heure",
"horizon.moderation.duration.hours": "%s heures",
"horizon.moderation.duration.day": "%s jour",
"horizon.moderation.duration.days": "%s jours",
"horizon.moderation.reason": "%1$s : %2$s",
"horizon.moderation.mutelist.empty": "Aucun joueur réduit au silence",
"horizon.moderation.mutelist.count": "Nombre de joueurs réduits au silence : %s",
"horizon.moderation.chat_filter": "le filtre de discussion",
"horizon.moderation.until_unmuted": "jusqu'au rétablissement de la parole",
"horizon.moderation.remaining": "pour encore %s",
"horizon.moderation.mutelist.entry": "%1$s a été réduit au silence par %2$s %3$s",
"horizon.moderation.invalid_duration": "%s n'est pas une durée : 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <joueur> [durée] [raison]",
"horizon.moderation.usage.tempmute": "/tempmute <joueur> <durée> [raison]",
"horizon.moderation.usage.unmute": "/unmute <joueur>",
"horizon.moderation.usage.mutelist": "/mutelist",
"horizon.moderation.for_duration": "pour %s",
"horizon.moderation.muted": "%1$s a été réduit au silence %2$s",
"horizon.moderation.unmuted": "%s peut de nouveau parler",
"horizon.moderation.not_muted": "%s n'est pas réduit au silence",
"horizon.tempban.usage": "/tempban <joueur> <durée> [raison]",
"horizon.tempban.invalid_duration": "%s n'est pas une durée : 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Les bannissements temporaires nécessitent un serveur dédié",
"horizon.tempban.success": "%1$s pour %2$s, jusqu'au %3$s",
"horizon.tempban.list.empty": "Il n'y a aucun bannissement temporaire",
"horizon.tempban.list.count": "Il y a %s bannissement(s) temporaire(s) :",
"horizon.tempban.list.entry": "%1$s a été banni par %2$s %3$s (jusqu'au %4$s)"
}
+13 -3
View File
@@ -331,6 +331,7 @@ mod test_environments;
mod test_instance_edit;
mod test_instances;
mod text_displays;
mod tick_profile;
mod titles;
mod tnt;
mod totem;
@@ -5720,13 +5721,15 @@ impl Game<'_> {
// ShulkerBoxBlock.canOpen: the lid has no room; nothing opens.
} else if let Some(entity) = BlockEntity::for_block(&name) {
self.world.block_entities.get_or_insert_with(key, || entity);
self.unpack_opened_loot(key, &name);
horizon_client::spans::time("open_loot", || self.unpack_opened_loot(key, &name));
let centre = [
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
];
self.resolve_container_maps(key, centre);
horizon_client::spans::time("open_maps", || {
self.resolve_container_maps(key, centre);
});
self.container = Some(key);
let first = self.container_opener_event(key, true);
self.post_block_game_event(
@@ -6374,9 +6377,13 @@ impl Game<'_> {
let mut anchor_passed = false;
// Native mods, where ServerPlayerGameMode.useItemOn starts: a
// canceled click does nothing (a guest's block is sent back).
// A slow tick's line names the phase of a use that took it (#8669): the mods' hook,
// the block's own use, then the item's.
if let Some(hit) = block_hit {
let mut canceled = false;
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
horizon_client::spans::time("use_mods", || {
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
});
if canceled {
// Nothing was used, so nothing consumed the action.
self.use_failed = true;
@@ -6384,6 +6391,7 @@ impl Game<'_> {
}
}
// MultiPlayerGameMode.useItemOn: blocks react first.
let block_phase = horizon_client::spans::guard("use_block");
if !(sneaking && hands_full)
&& let Some(hit) = block_hit
{
@@ -6566,6 +6574,8 @@ impl Game<'_> {
}
}
}
drop(block_phase);
let _item_phase = horizon_client::spans::guard("use_item");
let Some(stack) = self.held().cloned() else {
return Ok(false);
};
+238 -60
View File
@@ -11,53 +11,66 @@ use horizon_protocol::text::{literal, translatable};
use horizon_server_files::user_lists::{BanEntry, NameAndId};
use horizon_world::nbt::Tag;
/// One line that `/mute`, `/tempmute`, `/mutelist`, `/unmute` and `/tempban` answer with: Horizon's own words,
/// or vanilla's message where vanilla has one, which each client shows in its language.
/// A moderation reply retains its translation component for each client's language.
#[derive(Debug, PartialEq)]
pub(super) enum Reply {
Line(String),
/// A vanilla translation key with no argument.
Vanilla(&'static str),
}
pub(super) struct Reply(Text);
impl Reply {
/// `EntityArgument.NO_PLAYERS_FOUND`, `argument.entity.notfound.player`: what `/kick` and the other
/// player commands answer for a name no player has.
pub(super) fn translated(key: &str, args: &[Text]) -> Self {
Self(Self::translation(key, args))
}
/// Vanilla clients lack Horizon's keys, so preserve a readable fallback too.
pub(super) fn translation(key: &str, args: &[Text]) -> Text {
if key.starts_with("horizon.") {
static ENGLISH: std::sync::OnceLock<horizon_client::lang::Language> =
std::sync::OnceLock::new();
let english =
ENGLISH.get_or_init(|| horizon_client::lang::Language::load(&[], "en_us"));
Text::translate_with_fallback(key, english.get(key).unwrap_or(key), args)
} else {
Text::translate(key, args)
}
}
pub(super) fn no_player() -> Self {
Self::Vanilla("argument.entity.notfound.player")
Self::translated("argument.entity.notfound.player", &[])
}
/// `BanPlayerCommands.ERROR_ALREADY_BANNED`: `commands.ban.failed`.
pub(super) fn already_banned() -> Self {
Self::Vanilla("commands.ban.failed")
Self::translated("commands.ban.failed", &[])
}
pub(super) fn duration(millis: i64) -> Text {
use horizon_chat_filter::DurationUnit;
let (count, unit) = horizon_chat_filter::duration_parts(millis);
let key = match (unit, count == 1) {
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
(DurationUnit::Hour, true) => "horizon.moderation.duration.hour",
(DurationUnit::Hour, false) => "horizon.moderation.duration.hours",
(DurationUnit::Day, true) => "horizon.moderation.duration.day",
(DurationUnit::Day, false) => "horizon.moderation.duration.days",
};
Self::translation(key, &[Text::literal(count.to_string())])
}
pub(super) fn with_reason(self, reason: Option<&str>) -> Self {
match reason {
Some(reason) => Self::translated(
"horizon.moderation.reason",
&[self.0, Text::literal(reason)],
),
None => self,
}
}
/// The chat component a player is sent.
pub(super) fn tag(&self) -> Tag {
match self {
Self::Line(line) => literal(line),
Self::Vanilla(key) => translatable(key, &[]),
}
horizon_client::lan::data::component_nbt(&self.0.component())
}
/// The text the console prints.
pub(super) fn text(&self) -> Text {
match self {
Self::Line(line) => Text::literal(line.clone()),
Self::Vanilla(key) => Text::translate(key, &[]),
}
}
}
impl From<String> for Reply {
fn from(line: String) -> Self {
Self::Line(line)
}
}
impl From<&str> for Reply {
fn from(line: &str) -> Self {
Self::Line(line.to_owned())
self.0.clone()
}
}
@@ -67,6 +80,26 @@ const MUTE_LEVEL: u8 = 3;
/// The commands of `mute_command`.
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
/// The mute commands as operators' clients get them in the command tree (`command_tree`'s usages).
pub(super) const MUTE_USAGES: [&str; 4] = [
"mute <player:player> [duration_and_reason:text]",
"tempmute <player:player> <duration:word> [reason:text]",
"mutelist",
"unmute <player:player>",
];
/// `/mute <player> [duration] [reason]` (`words`, the command first): how long, when the word after
/// the name is a duration (`10m`, `2h`, `1d`; none: until `/unmute`), and the index the reason starts at.
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
match words
.get(2)
.and_then(|w| horizon_chat_filter::parse_duration(w))
{
Some(ms) => (Some(ms), 3),
None => (None, 2),
}
}
/// The reason a temporary ban gives at login.
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
@@ -251,33 +284,52 @@ impl Game<'_> {
let now = horizon_chat_filter::now_millis();
let mutes = host.hub.mutes();
if mutes.is_empty() {
return vec!["There are no mutes".into()];
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
}
let mut lines = vec![format!("There are {} mute(s):", mutes.len())];
let mut lines = vec![Reply::translated(
"horizon.moderation.mutelist.count",
&[Text::literal(mutes.len().to_string())],
)];
for m in mutes {
let by = m.by.as_deref().unwrap_or("the chat filter");
let by = m.by.as_deref().map_or_else(
|| Reply::translation("horizon.moderation.chat_filter", &[]),
Text::literal,
);
let until = m.until.map_or_else(
|| "until unmuted".into(),
|| Reply::translation("horizon.moderation.until_unmuted", &[]),
|u| {
let left = horizon_chat_filter::duration_text(u - now);
format!("for {}", left.replacen(' ', " more ", 1))
Reply::translation(
"horizon.moderation.remaining",
&[Reply::duration(u - now)],
)
},
);
let why = m.reason.map(|r| format!(": {r}")).unwrap_or_default();
lines.push(format!("{} was muted by {by} {until}{why}", m.name));
lines.push(
Reply::translated(
"horizon.moderation.mutelist.entry",
&[Text::literal(m.name), by, until],
)
.with_reason(m.reason.as_deref()),
);
}
lines.into_iter().map(Reply::from).collect()
lines
}
["unmute", name] => vec![self.unmute(host, name, by)],
["mute", name, ..] => {
let (time, from) = mute_time(words);
vec![self.mute(host, name, time, reason(from), by)]
}
["unmute", name] => vec![self.unmute(host, name, by).into()],
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![format!("{time} isn't a duration: 10m, 2h, 1d").into()],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
},
["mute", ..] => vec!["/mute <player> [reason]".into()],
["tempmute", ..] => vec!["/tempmute <player> <duration> [reason]".into()],
["unmute", ..] => vec!["/unmute <player>".into()],
_ => vec!["/mutelist".into()],
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
_ => vec![Reply::translated("horizon.moderation.usage.mutelist", &[])],
}
}
@@ -310,26 +362,31 @@ impl Game<'_> {
.as_deref()
.map(|r| format!(": {r}"))
.unwrap_or_default();
host.hub.mute(uuid, &name, until, by, reason);
host.hub.mute(uuid, &name, until, by, reason.clone());
if self.options.dedicated.is_some() {
dedicated::log(
"INFO",
&format!("[chat-filter] {by} muted {name} {what}{why}"),
);
}
format!("Muted {name} {what}{why}").into()
let until = time.map_or_else(
|| Reply::translation("horizon.moderation.until_unmuted", &[]),
|ms| Reply::translation("horizon.moderation.for_duration", &[Reply::duration(ms)]),
);
Reply::translated("horizon.moderation.muted", &[Text::literal(name), until])
.with_reason(reason.as_deref())
}
/// Lifts `name`'s mute, logged; what to answer.
pub(super) fn unmute(&mut self, host: &mut LanHost, name: &str, by: &str) -> String {
pub(super) fn unmute(&mut self, host: &mut LanHost, name: &str, by: &str) -> Reply {
match host.hub.unmute(name) {
Some(name) => {
if self.options.dedicated.is_some() {
dedicated::log("INFO", &format!("[chat-filter] {by} unmuted {name}"));
}
format!("Unmuted {name}")
Reply::translated("horizon.moderation.unmuted", &[Text::literal(name)])
}
None => format!("{name} isn't muted"),
None => Reply::translated("horizon.moderation.not_muted", &[Text::literal(name)]),
}
}
}
@@ -350,10 +407,131 @@ mod tests {
}
#[test]
fn horizons_own_lines_stay_literal() {
let reply = Reply::from("Muted Steve until unmuted");
assert_eq!(reply.tag(), literal("Muted Steve until unmuted"));
assert_eq!(reply.text().translation_key(), None);
assert_eq!(reply.text().plain(), "Muted Steve until unmuted");
fn horizons_replies_keep_nested_translation_components() {
let reply = Reply::translated(
"horizon.moderation.muted",
&[
Text::literal("Steve"),
Reply::translation(
"horizon.moderation.for_duration",
&[Reply::duration(61_000)],
),
],
)
.with_reason(Some("literal reason"));
let component = reply.text().component();
assert_eq!(component["translate"], "horizon.moderation.reason");
assert_eq!(
component["with"][0]["translate"],
"horizon.moderation.muted"
);
assert_eq!(
component["with"][0]["with"][1]["with"][0]["translate"],
"horizon.moderation.duration.minutes"
);
assert_eq!(component["with"][1]["text"], "literal reason");
assert_eq!(
reply.tag(),
horizon_client::lan::data::component_nbt(&component)
);
}
#[test]
fn clients_can_translate_nested_replies_or_use_the_fallback() {
use horizon_client::lang::{self, Language};
lang::scoped(Language::load(&[], "en_us"), || {
let reply = Reply::translated(
"horizon.moderation.muted",
&[
Text::literal("Steve"),
Reply::translation(
"horizon.moderation.for_duration",
&[Reply::duration(61_000)],
),
],
)
.with_reason(Some("reason %s"));
let wire = reply.tag();
assert_eq!(
horizon_protocol::text::plain(&wire, &Default::default()),
"Muted Steve for 2 minutes: reason %s"
);
let language = [
(
"horizon.moderation.reason".to_owned(),
"%1$s (%2$s)".to_owned(),
),
(
"horizon.moderation.muted".to_owned(),
"M %2$s %1$s".to_owned(),
),
(
"horizon.moderation.for_duration".to_owned(),
"D %s".to_owned(),
),
(
"horizon.moderation.duration.minutes".to_owned(),
"%s min_local".to_owned(),
),
]
.into_iter()
.collect();
assert_eq!(
horizon_protocol::text::plain(&wire, &language),
"M D 2 min_local Steve (reason %s)"
);
});
}
#[test]
fn bundled_languages_resolve_the_reply_on_the_receiving_client() {
use horizon_client::lang::{self, Language};
lang::scoped(Language::load(&[], "en_us"), || {
let reply = Reply::translated(
"horizon.moderation.muted",
&[
Text::literal("Steve"),
Reply::translation(
"horizon.moderation.for_duration",
&[Reply::duration(61_000)],
),
],
)
.with_reason(Some("reason %s"));
for (code, expected) in [
(
"fr_fr",
"Steve a été réduit au silence pour 2 minutes : reason %s",
),
(
"de_de",
"Steve wurde für 2 Minuten stummgeschaltet: reason %s",
),
(
"es_es",
"Se ha silenciado a Steve durante 2 minutos: reason %s",
),
] {
let language = Language::load(&[], code);
assert_eq!(
horizon_protocol::text::plain(&reply.tag(), language.map()),
expected,
"{code}"
);
}
});
}
#[test]
fn mute_takes_a_duration_after_the_name_or_mutes_until_unmuted() {
assert_eq!(mute_time(&["mute", "Steve"]), (None, 2));
assert_eq!(mute_time(&["mute", "Steve", "spamming", "chat"]), (None, 2));
assert_eq!(
mute_time(&["mute", "Steve", "1h", "spam"]),
(Some(3_600_000), 3)
);
assert_eq!(mute_time(&["mute", "Steve", "30m"]), (Some(1_800_000), 3));
// Not a duration: the word starts the reason.
assert_eq!(mute_time(&["mute", "Steve", "0m", "spam"]), (None, 2));
}
}
@@ -968,6 +968,7 @@ impl Game<'_> {
}
}
let uuid = self.level.player_uuid;
let level_id = self.dim.id();
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
let mut events = Vec::new();
let mut mobs = std::mem::take(&mut self.mobs);
@@ -1103,7 +1104,12 @@ impl Game<'_> {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
horizon_client::mobs::blocks_changed(m, &path_changes);
let from = events.len();
// `horizon-report`'s cost by mob type (tick_profile.rs).
let sampled = super::tick_profile::start().map(|t| (t, m.kind.clone()));
let kept = m.tick(&w, &mut scratch, &mut events);
if let Some((started, kind)) = sampled {
super::tick_profile::add(level_id, &kind, started);
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
+23 -5
View File
@@ -1243,9 +1243,16 @@ impl Game<'_> {
let ticked =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
}),
Some(recording) => {
let sampled = recording.sample_next();
super::tick_profile::set_sampling(sampled);
let ticked =
self.tick_server_spans(paused, |wall, cpu, spans, recorded| {
recording.add(wall, cpu, spans, recorded, sampled)
});
super::tick_profile::set_sampling(false);
ticked
}
None => self.tick_server_timed(paused),
}
}));
@@ -1512,6 +1519,17 @@ impl Game<'_> {
state.lists.lock().unwrap().stop = true;
}
// Horizon's own, on the console only: a performance report (report.rs).
"horizon-report" if rest.first() == Some(&"stop") => match state.report.take() {
Some(recording) => {
log("INFO", "Stopping the performance report: writing it now");
self.finish_report(recording, state.view, state.max_players);
}
None => log("INFO", "No performance report is being recorded"),
},
"horizon-report" if rest.first() == Some(&"status") => match &state.report {
Some(recording) => log("INFO", &recording.status()),
None => log("INFO", "No performance report is being recorded"),
},
"horizon-report" => {
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
None => Ok(60),
@@ -1522,7 +1540,7 @@ impl Game<'_> {
(Err(()), _) => log(
"INFO",
&format!(
"horizon-report [SECONDS]: 1 to {} seconds, 60 by default",
"horizon-report [SECONDS | stop | status]: 1 to {} seconds, 60 by default",
report::MAX_SECONDS
),
),
@@ -1530,7 +1548,7 @@ impl Game<'_> {
log("INFO", "A performance report is already being recorded")
}
(Ok(seconds), None) => {
state.report = Some(report::Recording::start(seconds));
state.report = Some(report::Recording::start(seconds, self.wire_bytes()));
log(
"INFO",
&format!("Recording a performance report for {seconds} s (reports/)"),
@@ -1,11 +1,17 @@
//! `horizon-report [SECONDS]` on the dedicated server's console (and `horizon-server report`): records every
//! tick's time and spans for a window (60 s unless told), then writes a performance report to share,
//! `reports/perf-<time>.json` and a short `.txt` beside it in the server's folder. It says where the ticks'
//! time went (the spans of `HORIZON_SPIKES`), shows the worst ticks with their spans, counts the entities by
//! type and the chunks of each level, and gives the memory, the CPU and the view settings. It holds no player
//! names, UUIDs or addresses, no seed and no world name.
use super::super::{Dim, Game};
use horizon_client::spans::{self, Span};
//! tick's time and spans for a window (60 s unless told; `horizon-report stop` ends it early, `horizon-report
//! status` says how far it is), then writes a performance report to share, `reports/perf-<time>.json` and a
//! short `.txt` beside it in the server's folder. It says where the ticks' time went (the spans of
//! `HORIZON_SPIKES`, each level's share), what each mob type costs (sampled, tick_profile.rs), what the chunk
//! workers spent their CPU on (load, generate, decorate, light...), the network bytes per player, and what
//! recording all this cost the tick. It shows the worst ticks with their spans, counts the entities by type and
//! the chunks of each level, and gives the memory, the CPU and the view settings. It holds no player names,
//! UUIDs or addresses, no seed and no world name. It runs beside the game: nothing restarts.
use super::super::{Dim, Game, tick_profile};
use horizon_client::{
spans::{self, Span},
streaming::job_stats,
};
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
@@ -17,6 +23,8 @@ use std::{
pub(super) const MAX_SECONDS: u32 = 600;
/// The worst ticks a report lists with their spans.
const WORST: usize = 10;
/// The mob types (per level) a report lists with their cost.
const MOB_TYPES: usize = 40;
struct Tick {
wall_ms: f32,
@@ -32,30 +40,64 @@ pub(super) struct Recording {
/// Whether spans were on before (`HORIZON_SPIKES`): they stay on after.
spans_were_on: bool,
ticks: Vec<Tick>,
/// The ticks whose mobs were timed one by one ([`tick_profile::SAMPLE_EVERY`]).
sampled_ticks: u64,
/// The spans recorded, before they are summed by name: what timing them cost.
spans_recorded: u64,
/// Each player's wire bytes (in, out) by connection when the recording started.
wire_start: BTreeMap<u32, (u64, u64)>,
}
impl Recording {
pub(super) fn start(seconds: u32) -> Self {
pub(super) fn start(seconds: u32, wire: Vec<(u32, (u64, u64))>) -> Self {
let spans_were_on = spans::enabled();
spans::enable(true);
job_stats::enable(true);
job_stats::take();
tick_profile::take();
Self {
seconds,
started: Instant::now(),
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
spans_were_on,
ticks: Vec::with_capacity(seconds as usize * 20),
sampled_ticks: 0,
spans_recorded: 0,
wire_start: wire.into_iter().collect(),
}
}
pub(super) fn add(&mut self, wall_ms: f32, cpu_ms: Option<f32>, spans: Vec<Span>) {
/// Whether the next tick's mobs are timed one by one.
pub(super) fn sample_next(&self) -> bool {
self.ticks.len().is_multiple_of(tick_profile::SAMPLE_EVERY)
}
pub(super) fn add(
&mut self,
wall_ms: f32,
cpu_ms: Option<f32>,
spans: Vec<Span>,
recorded: usize,
sampled: bool,
) {
self.ticks.push(Tick {
wall_ms,
cpu_ms,
spans,
});
self.sampled_ticks += u64::from(sampled);
self.spans_recorded += recorded as u64;
}
pub(super) fn done(&self) -> bool {
self.started.elapsed() >= Duration::from_secs(u64::from(self.seconds))
}
/// `horizon-report status`: how far the recording is.
pub(super) fn status(&self) -> String {
format!(
"Recording a performance report: {} of {} s, {} ticks (horizon-report stop writes it now)",
self.started.elapsed().as_secs(),
self.seconds,
self.ticks.len()
)
}
}
fn pct(sorted: &[f32], q: f64) -> f64 {
@@ -130,6 +172,126 @@ fn tick_summary(r: &Recording, window_s: f64) -> (Value, Value, Value) {
(ticks, spans, worst)
}
/// The sampled mob costs: per level and type, the milliseconds a tick, the mobs ticking, and the
/// microseconds one mob costs. Costliest first, the first [`MOB_TYPES`].
fn mob_costs(costs: &[tick_profile::MobCost], sampled_ticks: u64) -> Value {
let ticks = sampled_ticks.max(1) as f64;
Value::Array(
costs
.iter()
.take(MOB_TYPES)
.map(|c| {
json!({
"dimension": c.dimension,
"type": c.kind,
"ms_per_tick": round(c.nanos as f64 / ticks / 1e6),
"mobs_ticked": round(c.mob_ticks as f64 / ticks),
"us_per_mob": round(c.nanos as f64 / c.mob_ticks.max(1) as f64 / 1e3),
})
})
.collect(),
)
}
/// The chunk workers' CPU time by job kind (`streaming::job_stats`): cores used, jobs, ms a job.
fn worker_jobs(jobs: &[(&'static str, u64, u64)], window_s: f64) -> Value {
Value::Array(
jobs.iter()
.filter(|(_, _, count)| *count > 0)
.map(|&(kind, nanos, count)| {
json!({
"kind": kind,
"jobs": count,
"jobs_per_second": round(count as f64 / window_s.max(1e-9)),
"cpu_cores": round(nanos as f64 / 1e9 / window_s.max(1e-9)),
"ms_per_job": round(nanos as f64 / count as f64 / 1e6),
})
})
.collect(),
)
}
/// The players' network use over the window, from the wire bytes of the players on at both ends
/// (in KiB a second: average, P50, P95, max), and what all players on at the end sent since the
/// start (or since they joined).
fn network(start: &BTreeMap<u32, (u64, u64)>, end: &[(u32, (u64, u64))], window_s: f64) -> Value {
let kib_s = |bytes: u64| bytes as f64 / 1024.0 / window_s.max(1e-9);
let mut out = Vec::new();
let mut inn = Vec::new();
let (mut total_out, mut total_in) = (0u64, 0u64);
for (conn, (now_in, now_out)) in end {
let (was_in, was_out) = start.get(conn).copied().unwrap_or((0, 0));
let (d_in, d_out) = (
now_in.saturating_sub(was_in),
now_out.saturating_sub(was_out),
);
total_in += d_in;
total_out += d_out;
if start.contains_key(conn) {
out.push(kib_s(d_out) as f32);
inn.push(kib_s(d_in) as f32);
}
}
let stats = |v: &mut Vec<f32>| {
v.sort_by(f32::total_cmp);
let avg = v.iter().fold(0.0, |sum, &x| sum + f64::from(x)) / v.len().max(1) as f64;
json!({
"average": round(avg),
"p50": round(pct(v, 0.5)),
"p95": round(pct(v, 0.95)),
"max": round(v.last().copied().map_or(0.0, f64::from)),
})
};
json!({
"players_whole_window": out.len(),
"out_kib_per_s_per_player": stats(&mut out),
"in_kib_per_s_per_player": stats(&mut inn),
"total_out_kib_per_s": round(kib_s(total_out)),
"total_in_kib_per_s": round(kib_s(total_in)),
})
}
/// What recording cost the tick, priced on this machine at the end of the recording: every
/// sampled mob two clock reads, every span inside the tick two clocks and two thread-CPU clocks.
/// A dedicated server records spans whenever `HORIZON_SPIKES` is on (by default, for its slow-tick
/// line): then they are not the report's cost, and `ms_per_tick` leaves them out.
fn overhead(r: &Recording, mob_samples: u64, span_ns: f64, mob_ns: f64) -> Value {
let n = r.ticks.len().max(1) as f64;
let mspt = r.ticks.iter().map(|t| f64::from(t.wall_ms)).sum::<f64>() / n;
let spans_per_tick = r.spans_recorded as f64 / n;
let mobs_per_tick = mob_samples as f64 / n;
let spans_ms = spans_per_tick * span_ns / 1e6;
let mobs_ms = mobs_per_tick * mob_ns / 1e6;
let ms = mobs_ms + if r.spans_were_on { 0.0 } else { spans_ms };
let small = |v: f64| (v * 1e4).round() / 1e4;
json!({
"span_ns": round(span_ns),
"spans_per_tick": round(spans_per_tick),
"spans_ms_per_tick": small(spans_ms),
"spans_on_without_report": r.spans_were_on,
"mob_sample_ns": round(mob_ns),
"mob_samples_per_tick": round(mobs_per_tick),
"mob_samples_ms_per_tick": small(mobs_ms),
"ms_per_tick": small(ms),
"share_of_mspt": small(ms / mspt.max(1e-9)),
})
}
/// What one span costs on this thread with spans on: the report's own overhead. The spans it
/// records are thrown away.
fn span_cost_nanos() -> f64 {
const N: u32 = 5_000;
let kept = spans::take_raw();
let timer = Instant::now();
for _ in 0..N {
drop(spans::guard("calibration"));
}
let per = timer.elapsed().as_nanos() as f64 / f64::from(N);
spans::take_raw();
debug_assert!(kept.is_empty(), "the tick takes its spans");
per
}
impl Game<'_> {
/// The level in place and the parked ones: their chunks and their entities by type.
fn report_levels(&self) -> Value {
@@ -162,31 +324,63 @@ impl Game<'_> {
if let (Some(world), Some(mobs)) =
(self.levels.parked_world(dim), self.levels.parked_mobs(dim))
{
levels.push(here(dim, world, mobs));
let mut level = here(dim, world, mobs);
if let Some([items, orbs, projectiles, carts, boats]) =
self.levels.parked_entity_counts(dim)
{
level["item_entities"] = json!(items);
level["experience_orbs"] = json!(orbs);
level["projectiles"] = json!(projectiles);
level["minecarts"] = json!(carts);
level["boats"] = json!(boats);
}
levels.push(level);
}
}
Value::Array(levels)
}
/// Each player's wire bytes (in, out) by connection.
pub(super) fn wire_bytes(&self) -> Vec<(u32, (u64, u64))> {
self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter_map(|g| Some((g.conn, g.sender.wire_bytes()?)))
.collect()
})
}
/// The report of a finished recording: written to `reports/` in the server's folder, its summary logged.
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
let span_ns = span_cost_nanos();
let mob_ns = tick_profile::sample_cost_nanos();
if !r.spans_were_on {
spans::enable(false);
}
job_stats::enable(false);
let window_s = r.started.elapsed().as_secs_f64();
let (ticks, spans, worst) = tick_summary(&r, window_s);
let costs = tick_profile::take();
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
let jobs = job_stats::take();
let cpu = horizon_bench::cpu_seconds(std::process::id())
.zip(r.cpu_s)
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
let report = json!({
"format": 1,
"format": 2,
"horizon": horizon_client::crash::version(),
"minecraft": horizon_core::VANILLA_VERSION,
"window_s": round(window_s),
"ticks": ticks,
"spans": spans,
"worst_ticks": worst,
"mob_costs": mob_costs(&costs, r.sampled_ticks),
"mob_sampled_ticks": r.sampled_ticks,
"chunk_workers": worker_jobs(&jobs, window_s),
"network": network(&r.wire_start, &self.wire_bytes(), window_s),
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
"players": players,
"levels": self.report_levels(),
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
@@ -233,22 +427,79 @@ fn summary(r: &Value) -> String {
t["over_50_ms"],
t["over_100_ms"],
);
let spans: Vec<String> = r["spans"]
let span_list = |levels: bool, n: usize| -> Vec<String> {
r["spans"]
.as_array()
.into_iter()
.flatten()
.filter(|s| s["name"].as_str().unwrap_or("").starts_with("level_") == levels)
.take(n)
.map(|s| {
format!(
"{} {} ms",
s["name"].as_str().unwrap_or(""),
s["ms_per_tick"]
)
})
.collect()
};
out += &format!("per tick: {}\n", span_list(false, 5).join(", "));
let levels = span_list(true, 3);
if !levels.is_empty() {
out += &format!("levels: {}\n", levels.join(", "));
}
if let Some(w) = r["worst_ticks"].get(0) {
out += &format!("worst tick {} ms: {}\n", w["ms"], w["spans"]);
}
let costs: Vec<String> = r["mob_costs"]
.as_array()
.into_iter()
.flatten()
.take(5)
.map(|s| {
.map(|c| {
format!(
"{} {} ms",
s["name"].as_str().unwrap_or(""),
s["ms_per_tick"]
"{} {} ms ({} x {} us)",
c["type"].as_str().unwrap_or(""),
c["ms_per_tick"],
c["mobs_ticked"],
c["us_per_mob"]
)
})
.collect();
out += &format!("per tick: {}\n", spans.join(", "));
if let Some(w) = r["worst_ticks"].get(0) {
out += &format!("worst tick {} ms: {}\n", w["ms"], w["spans"]);
if !costs.is_empty() {
out += &format!("mobs: {}\n", costs.join(", "));
}
let workers: Vec<String> = r["chunk_workers"]
.as_array()
.into_iter()
.flatten()
.map(|w| {
format!(
"{} {}/s {} cores",
w["kind"].as_str().unwrap_or(""),
w["jobs_per_second"],
w["cpu_cores"]
)
})
.collect();
if !workers.is_empty() {
out += &format!("chunk workers: {}\n", workers.join(", "));
}
let net = &r["network"];
if net.is_object() {
out += &format!(
"network: {} KiB/s out in all, {} KiB/s a player (P95 {}, max {})\n",
net["total_out_kib_per_s"],
net["out_kib_per_s_per_player"]["average"],
net["out_kib_per_s_per_player"]["p95"],
net["out_kib_per_s_per_player"]["max"]
);
}
if let Some(o) = r["overhead"].as_object() {
out += &format!(
"recording cost {} ms a tick ({} of MSPT)\n",
o["ms_per_tick"], o["share_of_mspt"]
);
}
let mobs: u64 = r["levels"]
.as_array()
@@ -339,15 +590,23 @@ mod tests {
cpu_s: None,
spans_were_on: true,
ticks: Vec::new(),
sampled_ticks: 0,
spans_recorded: 0,
wire_start: BTreeMap::new(),
};
for i in 0..20 {
let wall = if i == 7 { 120.0 } else { 10.0 };
let sampled = r.sample_next();
r.add(
wall,
Some(wall - 1.0),
vec![span("tick_mobs", wall / 2.0), span("tick_lan", 2.0)],
2,
sampled,
);
}
assert_eq!(r.sampled_ticks, 5, "one tick in four");
assert_eq!(r.spans_recorded, 40);
let (ticks, spans, worst) = tick_summary(&r, 1.0);
assert_eq!(ticks["count"], 20);
assert_eq!(ticks["tps"], 20.0);
@@ -385,4 +644,143 @@ mod tests {
"{text}"
);
}
/// A mob type's sampled time is spread over the sampled ticks; one mob's cost over its ticks.
#[test]
fn mob_costs_are_per_sampled_tick_and_per_mob() {
let costs = [
tick_profile::MobCost {
dimension: "minecraft:overworld",
kind: "minecraft:villager".into(),
// 10 sampled ticks of 200 villagers at 25 us each.
nanos: 10 * 200 * 25_000,
mob_ticks: 10 * 200,
},
tick_profile::MobCost {
dimension: "minecraft:the_end",
kind: "minecraft:enderman".into(),
nanos: 10 * 40 * 2_000,
mob_ticks: 10 * 40,
},
];
let v = mob_costs(&costs, 10);
assert_eq!(v[0]["type"], "minecraft:villager");
assert_eq!(v[0]["ms_per_tick"], 5.0);
assert_eq!(v[0]["mobs_ticked"], 200.0);
assert_eq!(v[0]["us_per_mob"], 25.0);
assert_eq!(v[1]["dimension"], "minecraft:the_end");
assert_eq!(v[1]["ms_per_tick"], 0.08);
assert_eq!(mob_costs(&[], 0), json!([]));
}
/// Per-player rates count only the players on for the whole window; the total also counts
/// those who joined during it, from zero.
#[test]
fn network_rates_per_player_and_in_all() {
let start = BTreeMap::from([(1, (0, 0)), (2, (5_000, 50_000)), (9, (1, 1))]);
let end = [
(1, (10 * 1024, 100 * 1024 * 10)),
(2, (5_000 + 20 * 1024, 50_000 + 300 * 1024 * 10)),
// Joined during the window.
(3, (0, 600 * 1024 * 10)),
];
let v = network(&start, &end, 10.0);
assert_eq!(v["players_whole_window"], 2);
assert_eq!(v["out_kib_per_s_per_player"]["average"], 200.0);
assert_eq!(v["out_kib_per_s_per_player"]["max"], 300.0);
assert_eq!(v["in_kib_per_s_per_player"]["average"], 1.5);
assert_eq!(v["total_out_kib_per_s"], 1000.0);
}
/// Worker kinds that ran no job are left out; CPU time over the window is cores.
#[test]
fn worker_jobs_give_cores_and_job_times() {
let jobs = [
("load", 0, 0),
("generate", 30_000_000_000, 6_000),
("light", 2_000_000_000, 4_000),
];
let v = worker_jobs(&jobs, 60.0);
assert_eq!(v.as_array().unwrap().len(), 2);
assert_eq!(v[0]["kind"], "generate");
assert_eq!(v[0]["cpu_cores"], 0.5);
assert_eq!(v[0]["jobs_per_second"], 100.0);
assert_eq!(v[0]["ms_per_job"], 5.0);
assert_eq!(v[1]["ms_per_job"], 0.5);
}
/// The recording's cost: spans and mob samples a tick at their measured price, against MSPT.
#[test]
fn overhead_is_measured_against_the_tick() {
let mut r = Recording {
seconds: 1,
started: Instant::now(),
cpu_s: None,
spans_were_on: true,
ticks: Vec::new(),
sampled_ticks: 0,
spans_recorded: 0,
wire_start: BTreeMap::new(),
};
for _ in 0..4 {
r.add(50.0, None, Vec::new(), 100, false);
}
// Spans were off before: 100 spans at 200 ns + 1,000 mob samples a tick at 50 ns = 0.07 ms
// of 50.
r.spans_were_on = false;
let v = overhead(&r, 4_000, 200.0, 50.0);
assert_eq!(v["spans_ms_per_tick"], 0.02);
assert_eq!(v["ms_per_tick"], 0.07);
assert_eq!(v["share_of_mspt"], 0.0014);
// Spans on anyway (a dedicated server's slow-tick line): only the mob samples are the report's.
r.spans_were_on = true;
let v = overhead(&r, 4_000, 200.0, 50.0);
assert_eq!(v["ms_per_tick"], 0.05);
assert_eq!(v["share_of_mspt"], 0.001);
// On this machine, both prices are well under a microsecond's order.
let (span_ns, mob_ns) = (span_cost_nanos(), tick_profile::sample_cost_nanos());
assert!(span_ns > 0.0 && span_ns < 50_000.0, "{span_ns}");
assert!(mob_ns > 0.0 && mob_ns < 50_000.0, "{mob_ns}");
}
/// The summary lists the levels apart from the systems, and the new sections when present.
#[test]
fn summary_lists_levels_mobs_workers_network_and_cost() {
let r = json!({
"ticks": {"tps": 20.0, "mspt": {"average": 40.0, "p50": 39.0, "p95": 45.0, "p99": 50.0, "max": 60.0},
"over_50_ms": 1, "over_100_ms": 0},
"spans": [{"name": "level_overworld", "ms_per_tick": 30.0}, {"name": "tick_mobs", "ms_per_tick": 20.0},
{"name": "level_the_end", "ms_per_tick": 9.0}],
"worst_ticks": [],
"mob_costs": [{"type": "minecraft:villager", "ms_per_tick": 5.0, "mobs_ticked": 200.0, "us_per_mob": 25.0}],
"chunk_workers": [{"kind": "generate", "jobs_per_second": 100.0, "cpu_cores": 0.5}],
"network": {"total_out_kib_per_s": 1000.0, "out_kib_per_s_per_player": {"average": 200.0, "p95": 300.0, "max": 300.0}},
"overhead": {"ms_per_tick": 0.07, "share_of_mspt": 0.0014},
"players": 2, "levels": [], "memory": {"rss_mb": 1}, "cpu_cores_used": 1.0,
});
let text = summary(&r);
assert!(text.contains("per tick: tick_mobs 20.0 ms\n"), "{text}");
assert!(
text.contains("levels: level_overworld 30.0 ms, level_the_end 9.0 ms\n"),
"{text}"
);
assert!(
text.contains("mobs: minecraft:villager 5.0 ms (200.0 x 25.0 us)\n"),
"{text}"
);
assert!(
text.contains("chunk workers: generate 100.0/s 0.5 cores\n"),
"{text}"
);
assert!(
text.contains(
"network: 1000.0 KiB/s out in all, 200.0 KiB/s a player (P95 300.0, max 300.0)"
),
"{text}"
);
assert!(
text.contains("recording cost 0.07 ms a tick (0.0014 of MSPT)"),
"{text}"
);
}
}
+71 -12
View File
@@ -1840,7 +1840,8 @@ impl Game<'_> {
for event in events {
if timed {
note_event(&host, previous.take());
previous = Some((Instant::now(), event_kind(&event)));
let detail = self.event_detail(&host, &event);
previous = Some((Instant::now(), event_kind(&event), detail));
}
match event {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
@@ -2287,7 +2288,7 @@ impl Game<'_> {
// this tick to their clients. Otherwise a newly broken block and its
// drops remain absent from the snapshot until the following tick.
if !paused {
self.tick_guests(&mut host);
horizon_client::spans::time("lan_guests", || self.tick_guests(&mut host));
// The vehicles the guests just moved enter their portals now.
let ridden: Vec<u32> = host.hub.guests.iter().filter_map(|g| g.vehicle).collect();
self.find_ridden_vehicle_portals(&ridden);
@@ -2304,9 +2305,11 @@ impl Game<'_> {
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(&guests, &mut host.hub.riding);
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true, 0);
self.lan_hook_states(&host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
horizon_client::spans::time("lan_entity_states", || {
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true, 0);
self.lan_hook_states(&host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
});
host.states_next_entity = Some(self.next_entity);
} else {
host.states_next_entity = None;
@@ -2402,14 +2405,19 @@ impl Game<'_> {
if !pass.packets_drained {
events.extend(host.hub.drain_packets(&mut ctx));
}
events.extend(host.hub.tick_level(&mut ctx));
events.extend(horizon_client::spans::time("lan_tick_level", || {
host.hub.tick_level(&mut ctx)
}));
if pass.last {
host.hub.end_tick();
horizon_client::spans::time("lan_end_tick", || host.hub.end_tick());
}
events
};
self.wake_woken_guests(&mut host);
let Some(back) = self.lan_process_events(host, events, entities, spawn) else {
let processed = horizon_client::spans::time("lan_process_events", || {
self.lan_process_events(host, events, entities, spawn)
});
let Some(back) = processed else {
return;
};
host = back;
@@ -3060,22 +3068,73 @@ fn event_kind(event: &HostEvent) -> EventKind {
}
}
/// Notes how long the event that started at `started` took (`spans::note`).
fn note_event(host: &LanHost, started: Option<(Instant, EventKind)>) {
let Some((started, (kind, conn, name))) = started else {
/// Notes how long the event that started at `started` took (`spans::note`), with what it
/// was about when [`Game::event_detail`] said.
fn note_event(host: &LanHost, started: Option<(Instant, EventKind, Option<UseDetail>)>) {
let Some((started, (kind, conn, name), detail)) = started else {
return;
};
horizon_client::spans::note(started.elapsed().as_secs_f32() * 1000.0, || {
let who = name
.or_else(|| conn.and_then(|c| host.hub.guest(c)).map(|g| g.name.clone()))
.or_else(|| conn.map(|c| format!("connection {c}")));
match who {
let line = match who {
Some(who) => format!("{kind} of {who}"),
None => kind.to_owned(),
};
match detail {
Some(d) => format!("{line} ({d})"),
None => line,
}
});
}
/// What a use packet was about, taken before it ran (the use may consume the item or change
/// the block): the item in the hand it names and, on a block, that block, where, and the face.
struct UseDetail {
item: Option<Arc<str>>,
block: Option<(Arc<str>, [i32; 3], i32)>,
}
impl std::fmt::Display for UseDetail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.item.as_deref().unwrap_or("empty hand"))?;
if let Some((block, [x, y, z], face)) = &self.block {
let face = ["down", "up", "north", "south", "west", "east"]
.get(*face as usize)
.copied()
.unwrap_or("?");
write!(f, " on {block} at {x} {y} {z} {face}")?;
}
Ok(())
}
}
impl Game<'_> {
/// For a slow tick's note (#8669): the item a `UseItemOn` or `UseItem` uses and the block
/// it aims at. None for other events.
fn event_detail(&self, host: &LanHost, event: &HostEvent) -> Option<UseDetail> {
let HostEvent::Packet(conn, packet) = event else {
return None;
};
let (hand, block) = match &**packet {
play::Serverbound::UseItemOn(p) => (p.hand, Some((p.pos, p.face))),
play::Serverbound::UseItem(p) => (p.hand, None),
_ => return None,
};
let guest = host.hub.guest(*conn)?;
let held = if hand == 1 {
guest.offhand.as_ref()
} else {
guest.inventory.get(guest.selected).and_then(Option::as_ref)
};
Some(UseDetail {
item: held.map(|s| Arc::clone(&s.item)),
block: block.map(|(pos, face)| (self.block_name(pos), pos, face)),
})
}
}
#[cfg(test)]
mod tests {
use super::{OFFHAND_SLOT, equipment_slot_name, living_flags, mob_freeze_meta};
@@ -2096,7 +2096,9 @@ impl Game<'_> {
let stack = g
.with_hand(hand, |g| g.held().cloned())
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
g.trigger_any_block_use(p.pos, &stack);
horizon_client::spans::time("use_advancements", || {
g.trigger_any_block_use(p.pos, &stack);
});
}
g.forced_hit = None;
g.guest_use = None;
+37 -6
View File
@@ -154,6 +154,20 @@ impl Levels {
self.parked.get(&dim).map(|level| level.mobs.as_slice())
}
/// A parked level's loose entities, for `horizon-report`: items, experience orbs,
/// projectiles, minecarts and boats.
pub(super) fn parked_entity_counts(&self, dim: Dim) -> Option<[usize; 5]> {
self.parked.get(&dim).map(|l| {
[
l.dropped.as_slice().len(),
l.orbs.as_slice().len(),
l.projectiles.as_slice().len(),
l.carts.as_slice().len(),
l.boats.as_slice().len(),
]
})
}
/// The levels that are not in place, by dimension.
/// The forced chunks of every level not in place, released.
pub(super) fn release_parked_forced_chunks(&mut self) {
@@ -440,6 +454,13 @@ impl Game<'_> {
let n = dims.len();
let mut result = Ok(());
for (k, dim) in dims.into_iter().enumerate() {
// Each level's share of the tick (the first one also reads the network and runs the
// tick's shared parts): `horizon-report`'s `level_*` spans.
let _level_span = horizon_client::spans::guard(match dim {
Dim::Overworld => "level_overworld",
Dim::Nether => "level_the_nether",
Dim::End => "level_the_end",
});
if let Err(e) = self.enter_level(None, dim) {
result = Err(e);
break;
@@ -514,20 +535,24 @@ impl Game<'_> {
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
/// tick longer than MS is logged with its thread CPU time and spans.
pub(super) fn tick_server_timed(&mut self, paused: bool) -> Result<()> {
self.tick_server_spans(paused, |_, _, _| {})
self.tick_server_spans(paused, |_, _, _, _| {})
}
/// `tick_server_timed`, handing the tick's wall time, thread CPU time
/// and spans (milliseconds) to `record` while spans are on: a dedicated
/// server's performance report turns them on to record its ticks.
/// and spans (milliseconds, summed by name) to `record` while spans are on, with how many
/// spans the tick itself recorded: a dedicated server's performance report turns them on to
/// record its ticks.
pub(super) fn tick_server_spans(
&mut self,
paused: bool,
record: impl FnOnce(f32, Option<f32>, Vec<horizon_client::spans::Span>),
record: impl FnOnce(f32, Option<f32>, Vec<horizon_client::spans::Span>, usize),
) -> Result<()> {
if self.options.spikes.is_none() && !horizon_client::spans::enabled() {
return self.tick_server(paused);
}
// Spans recorded since the last tick ran between ticks (the chunk pump of the wait): they
// stay in the tick's list, but only the tick's own count as recorded in it.
let between = horizon_client::spans::take_raw();
let (started, cpu) = (Instant::now(), horizon_client::spans::thread_cpu());
let result = self.tick_server(paused);
let wall = started.elapsed().as_secs_f32() * 1000.0;
@@ -535,7 +560,13 @@ impl Game<'_> {
.zip(cpu)
.map(|(end, start)| (end - start).as_secs_f32() * 1000.0);
// Taken every tick: nothing else takes this thread's spans.
let spans = horizon_client::spans::take();
let raw = horizon_client::spans::take_raw();
let in_tick = raw.len();
let spans = if between.is_empty() {
horizon_client::spans::merge(&raw)
} else {
horizon_client::spans::merge(&[between, raw].concat())
};
let notes = horizon_client::spans::take_notes();
if let Some(threshold) = self.options.spikes
&& wall > threshold
@@ -548,7 +579,7 @@ impl Game<'_> {
slowest_steps(&notes)
);
}
record(wall, cpu, spans);
record(wall, cpu, spans, in_tick);
result
}
@@ -911,11 +911,23 @@ impl Game<'_> {
}
let mut commands = Vec::new();
self.native_register_commands(&mut commands);
let (tree, levels, problems) =
// Horizon's own moderation commands go in the same tree, so operators' clients complete them
// instead of showing them as unknown; the game still runs them itself (`run_guest_command`).
let extras = super::tempban::moderation_commands(
host.hub.chat_filter_on(),
self.server_lists.is_some(),
);
commands.extend(extras.iter().cloned());
let (tree, mut levels, problems) =
command_tree(&commands, |name| host.hub.vanilla_command(name));
for p in problems {
log::warn!("Horizon: a native mod's command was left out: {p}");
}
levels.retain(|name, _| {
!extras
.iter()
.any(|c| c.usage.split(' ').next() == Some(name.as_str()))
});
host.hub.set_mod_commands(0, tree);
host.native_commands = Some(levels);
}
+6
View File
@@ -769,12 +769,18 @@ impl Game<'_> {
/// and moving over blocks.
pub(super) fn tick_sculk_entities(&mut self) {
self.sculk.ticks = self.sculk.ticks.wrapping_add(1);
// Its steps as spans for `horizon-report` (nothing is read while spans are off).
let mut laps = horizon_client::spans::laps();
self.sculk_prepare();
laps.lap("sculk_prepare");
self.load_warden_tracker();
self.sculk.tracker.tick();
self.tick_darkness_blend();
laps.lap("sculk_wardens");
self.sculk_step_on();
laps.lap("sculk_step_on");
self.tick_entity_events();
laps.lap("sculk_entity_events");
}
/// The block entity phase for sculk: vibration tickers and catalysts, in
/// the order the block entities were added (`Level.tickBlockEntities`).
+278 -21
View File
@@ -2,10 +2,12 @@
//! itself. It is a `banned-players.json` entry with `expires` set, as the chat filter's automatic temporary
//! ban makes (game/chat_filter.rs), so the login check, `/banlist` and vanilla's `/pardon` already treat it as
//! they treat any ban, and it survives a restart. Operators of level 3 (`/ban`'s level) run it as a player; the
//! console takes the same words without the slash. Vanilla has no such command, so it is not in the command
//! tree (and has no completions), as the mute commands are not.
//! console takes the same words without the slash. `/tempbanlist` lists the bans that end by themselves, with
//! the time left. Vanilla has no such commands: they reach operators' command trees (completions included)
//! as a native mod's commands do (`moderation_commands`), and the game runs them itself.
use super::{Game, chat_filter::Reply, dedicated, lan::LanHost};
use horizon_chat_filter::{duration_text, now_millis, parse_duration};
use horizon_client::{mod_api::NativeCommand, text::Text};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_protocol::text::translatable;
use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date};
@@ -13,10 +15,63 @@ use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date
/// The operator level `/tempban` needs, as `/ban`'s.
const TEMPBAN_LEVEL: u8 = 3;
/// The commands of `tempban_command`.
pub(super) const TEMPBAN_COMMANDS: [&str; 1] = ["tempban"];
/// The commands of `tempban_command`: `/tempbanlist` lists the bans that end by themselves.
pub(super) const TEMPBAN_COMMANDS: [&str; 2] = ["tempban", "tempbanlist"];
const USAGE: &str = "/tempban <player> <duration> [reason]";
/// Horizon's moderation commands in operators' command trees (`command_tree`'s usages, for operators of
/// `/ban`'s level): the mute commands when the server filters chat (`chat_filter`), `/tempban` and
/// `/tempbanlist` on a dedicated server (`dedicated`), where the game runs them.
pub(super) fn moderation_commands(chat_filter: bool, dedicated: bool) -> Vec<NativeCommand> {
let mut usages = Vec::new();
if chat_filter {
usages.extend(super::chat_filter::MUTE_USAGES);
}
if dedicated {
usages.extend([
"tempban <player:player> <duration:word> [reason:text]",
"tempbanlist",
]);
}
usages
.into_iter()
.map(|usage| NativeCommand::op(usage, TEMPBAN_LEVEL))
.collect()
}
/// `/tempbanlist`: the bans that end by themselves and haven't yet (`now`), the soonest to end first,
/// with who banned, the time left and the reason.
fn timed_bans(bans: &[&BanEntry<NameAndId>], now: i64) -> Vec<Reply> {
let mut timed: Vec<(&BanEntry<NameAndId>, i64)> = bans
.iter()
.filter_map(|b| b.expires.filter(|&e| e >= now).map(|e| (*b, e)))
.collect();
if timed.is_empty() {
return vec![Reply::translated("horizon.tempban.list.empty", &[])];
}
timed.sort_by_key(|&(b, e)| (e, b.user.name.clone()));
let mut lines = vec![Reply::translated(
"horizon.tempban.list.count",
&[Text::literal(timed.len().to_string())],
)];
for (ban, expires) in timed {
lines.push(
Reply::translated(
"horizon.tempban.list.entry",
&[
Text::literal(&ban.user.name),
Text::literal(&ban.source),
Reply::translation(
"horizon.moderation.remaining",
&[Reply::duration(expires - now)],
),
Text::literal(format_date(expires)),
],
)
.with_reason(ban.reason.as_deref()),
);
}
lines
}
/// What `/tempban` was asked: the player's name, how long, and the reason, or the line to answer.
#[derive(Debug, PartialEq)]
@@ -28,12 +83,15 @@ struct Request<'a> {
/// `words` are the command and its arguments; the duration is `parse_duration`'s: a whole number and
/// `s`, `m`, `h`, `d` or `w` (`30m`, `12h`, `7d`).
fn parse<'a>(words: &[&'a str]) -> Result<Request<'a>, String> {
fn parse<'a>(words: &[&'a str]) -> Result<Request<'a>, Reply> {
let [_, name, time, reason @ ..] = words else {
return Err(USAGE.into());
return Err(Reply::translated("horizon.tempban.usage", &[]));
};
let Some(millis) = parse_duration(time) else {
return Err(format!("{time} isn't a duration: 30m, 12h, 7d"));
return Err(Reply::translated(
"horizon.tempban.invalid_duration",
&[Text::literal(*time)],
));
};
Ok(Request {
name,
@@ -73,6 +131,24 @@ fn add_ban(
Some(ban)
}
fn success_reply(ban: &BanEntry<NameAndId>, millis: i64, expires: i64) -> Reply {
let reason = ban.reason.as_ref().map_or_else(
|| Text::translate("multiplayer.disconnect.banned.reason.default", &[]),
Text::literal,
);
Reply::translated(
"horizon.tempban.success",
&[
Text::translate(
"commands.ban.success",
&[Text::literal(&ban.user.name), reason],
),
Reply::duration(millis),
Text::literal(format_date(expires)),
],
)
}
impl Game<'_> {
/// A guest's `/tempban`: operators of level 3 (`/ban`'s); vanilla's unknown command for the others.
pub(super) fn guest_tempban_command(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
@@ -107,9 +183,16 @@ impl Game<'_> {
words: &[&str],
by: &str,
) -> Vec<Reply> {
if words.first() == Some(&"tempbanlist") {
let Some(lists) = self.server_lists.clone() else {
return vec![Reply::translated("horizon.tempban.dedicated_required", &[])];
};
let lists = lists.lock().unwrap();
return timed_bans(&lists.bans.entries(), now_millis());
}
match parse(words) {
Ok(r) => vec![self.tempban(host, r.name, r.millis, r.reason, by)],
Err(line) => vec![line.into()],
Err(line) => vec![line],
}
}
@@ -123,7 +206,7 @@ impl Game<'_> {
by: &str,
) -> Reply {
let Some(lists) = self.server_lists.clone() else {
return "Temporary bans need a dedicated server".into();
return Reply::translated("horizon.tempban.dedicated_required", &[]);
};
// A player here, else one the server has seen (the user cache).
let user = host
@@ -180,12 +263,7 @@ impl Game<'_> {
"until": ban.expires.map(format_date),
}),
);
format!(
"Banned {} for {what}, until {}{why}",
user.name,
format_date(now + millis)
)
.into()
success_reply(&ban, millis, now + millis)
}
}
@@ -223,8 +301,15 @@ mod tests {
}
#[test]
fn bad_words_get_a_usage_or_the_bad_duration() {
assert_eq!(parse(&["tempban"]), Err(USAGE.into()));
assert_eq!(parse(&["tempban", "Steve"]), Err(USAGE.into()));
for words in [&["tempban"][..], &["tempban", "Steve"][..]] {
assert_eq!(
parse(words).unwrap_err().text().component(),
serde_json::json!({
"translate": "horizon.tempban.usage",
"fallback": "/tempban <player> <duration> [reason]"
})
);
}
for bad in [
"soon",
"0m",
@@ -234,13 +319,85 @@ mod tests {
"10",
"99999999999999999999d",
] {
let reply = parse(&["tempban", "Steve", bad]).unwrap_err();
let component = reply.text().component();
assert_eq!(component["translate"], "horizon.tempban.invalid_duration");
let argument = if bad == "10" {
serde_json::json!(10)
} else {
serde_json::json!({"text": bad})
};
assert_eq!(component["with"], serde_json::json!([argument]), "{bad}");
assert_eq!(
parse(&["tempban", "Steve", bad]),
Err(format!("{bad} isn't a duration: 30m, 12h, 7d")),
"{bad}"
horizon_client::scoreboard::tag_to_json(&reply.tag()),
component
);
}
}
#[test]
fn success_keeps_vanilla_reply_and_ban_data_nested_for_each_clients_language() {
let now = 1_800_000_000_000;
let millis = 12 * 3_600_000;
for reason in [None, Some("griefing the farm".to_owned())] {
let ban = entry(user(7, "Steve"), "Alex", now, millis, reason.clone());
let reply = success_reply(&ban, millis, now + millis);
let component = reply.text().component();
assert_eq!(component["translate"], "horizon.tempban.success");
assert_eq!(component["with"][0]["translate"], "commands.ban.success");
assert_eq!(
component["with"][0]["with"][0],
serde_json::json!({"text": "Steve"})
);
let expected_reason = match &reason {
Some(reason) => serde_json::json!({"text": reason}),
None => {
serde_json::json!({"translate": "multiplayer.disconnect.banned.reason.default"})
}
};
assert_eq!(component["with"][0]["with"][1], expected_reason);
assert!(
component["with"][1]["translate"]
.as_str()
.is_some_and(|k| k.starts_with("horizon."))
);
assert_eq!(
component["with"][2],
serde_json::json!({"text": format_date(now + millis)})
);
assert_eq!(
horizon_client::scoreboard::tag_to_json(&reply.tag()),
component
);
assert_eq!(ban.reason, reason);
assert_eq!(ban.expires, Some(now + millis));
}
}
#[test]
fn initialized_console_language_resolves_the_vanilla_message_and_duration() {
use horizon_client::lang::{self, Language, ResourceSource};
struct Vanilla;
impl ResourceSource for Vanilla {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
br#"{
"commands.ban.success": "ban-local %1$s / %2$s",
"multiplayer.disconnect.banned.reason.default": "default-local"
}"#
.to_vec()
})
}
}
lang::scoped(Language::load(&[&Vanilla], "en_us"), || {
let ban = entry(user(7, "Steve"), "Alex", 0, 12 * 3_600_000, None);
let reply = success_reply(&ban, 12 * 3_600_000, 0);
assert_eq!(
reply.text().plain(),
"ban-local Steve / default-local for 12 hours, until 1970-01-01 00:00:00 +0000"
);
});
}
#[test]
fn the_ban_ends_after_its_duration_and_keeps_who_and_why() {
let mut bans = list("ends");
@@ -310,4 +467,104 @@ mod tests {
assert!(after.get(&user(4, "Eve")).is_none());
let _ = std::fs::remove_file(&bans.file);
}
fn ban(
id: u128,
name: &str,
by: &str,
expires: Option<i64>,
reason: Option<&str>,
) -> BanEntry<NameAndId> {
let mut b = BanEntry::new(
user(id, name),
Some(by.into()),
expires,
reason.map(Into::into),
);
b.created = 0;
b
}
#[test]
fn tempbanlist_lists_bans_still_running_soonest_first_with_time_left() {
let now = 1_800_000_000_000;
let hour = 3_600_000;
let bans = [
ban(1, "Forever", "rdbtclient", None, Some("x-ray")),
ban(2, "Ended", "luzW", Some(now - 1), None),
ban(
3,
"Week",
"luzW",
Some(now + 7 * 24 * hour),
Some("griefing"),
),
ban(4, "Hour", "Server", Some(now + hour), None),
];
let text = |bans: &[BanEntry<NameAndId>]| -> Vec<String> {
timed_bans(&bans.iter().collect::<Vec<_>>(), now)
.iter()
.map(|l| horizon_protocol::text::plain(&l.tag(), &Default::default()))
.collect()
};
assert_eq!(
text(&bans),
[
"There are 2 temporary ban(s):".to_owned(),
format!(
"Hour was banned by Server for another 60 minutes (until {})",
format_date(now + hour)
),
format!(
"Week was banned by luzW for another 7 days (until {}): griefing",
format_date(now + 7 * 24 * hour)
),
]
);
// Only permanent or ended bans: nothing to list.
assert_eq!(text(&bans[..2]), ["There are no temporary bans"]);
}
#[test]
fn moderation_commands_reach_only_operators_trees_and_only_where_the_game_runs_them() {
let names = |chat_filter, dedicated| {
let (tree, levels, problems) = super::super::native_server::command_tree(
&moderation_commands(chat_filter, dedicated),
|_| false,
);
assert!(problems.is_empty(), "{problems:?}");
for level in levels.values() {
assert_eq!(*level, 3);
}
if let Some(children) = tree["children"].as_object() {
for (name, node) in children {
assert_eq!(
node["permissions"],
serde_json::json!({"type": "require", "permission": {"level": "admins"}}),
"{name}"
);
}
}
let mut names: Vec<String> = levels.into_keys().collect();
names.sort();
names
};
assert_eq!(
names(true, true),
[
"mute",
"mutelist",
"tempban",
"tempbanlist",
"tempmute",
"unmute"
]
);
assert_eq!(names(false, true), ["tempban", "tempbanlist"]);
assert_eq!(
names(true, false),
["mute", "mutelist", "tempmute", "unmute"]
);
assert!(names(false, false).is_empty());
}
}
@@ -0,0 +1,155 @@
//! What each kind of mob costs the server tick, for `horizon-report` (dedicated/report.rs): while
//! a report records, one tick in [`SAMPLE_EVERY`] times every mob's own tick and adds it up by
//! level and mob type. Off, a mob costs one thread-local read; sampled, two clock reads and a
//! map update. A mob's riders tick inside its own tick, so they count for it.
use horizon_client::{fast_hash::FxHashMap, mobs::MobType};
use std::{
cell::{Cell, RefCell},
time::Instant,
};
/// One server tick in this many is sampled while a report records.
pub(super) const SAMPLE_EVERY: usize = 4;
/// The time and the ticks of one mob type in one level.
struct Cost {
kind: String,
nanos: u64,
mob_ticks: u64,
}
thread_local! {
static SAMPLING: Cell<bool> = const { Cell::new(false) };
static COSTS: RefCell<FxHashMap<(&'static str, usize), Cost>> = RefCell::default();
}
/// Sample (or stop sampling) the mob ticks this thread runs next.
pub(super) fn set_sampling(on: bool) {
SAMPLING.with(|s| s.set(on));
}
/// When this tick is sampled, the start of a mob's tick.
#[inline]
pub(super) fn start() -> Option<Instant> {
SAMPLING.with(Cell::get).then(Instant::now)
}
/// A sampled mob of type `kind` in level `dim` ticked since `started`.
#[inline]
pub(super) fn add(dim: &'static str, kind: &MobType, started: Instant) {
add_kind(dim, std::ptr::from_ref(kind) as usize, &kind.id, started);
}
/// [`add`] for the type at address `key` named `id`.
fn add_kind(dim: &'static str, key: usize, id: &str, started: Instant) {
let nanos = started.elapsed().as_nanos() as u64;
COSTS.with(|c| {
let mut costs = c.borrow_mut();
let cost = costs.entry((dim, key)).or_insert_with(|| Cost {
kind: id.to_owned(),
nanos: 0,
mob_ticks: 0,
});
cost.nanos += nanos;
cost.mob_ticks += 1;
});
}
/// One level's mob type: its sampled time and mob ticks.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct MobCost {
pub dimension: &'static str,
pub kind: String,
pub nanos: u64,
pub mob_ticks: u64,
}
/// The costs sampled since the last call, costliest first; clears them.
pub(super) fn take() -> Vec<MobCost> {
let costs = COSTS.with(|c| std::mem::take(&mut *c.borrow_mut()));
let mut out: Vec<MobCost> = costs
.into_iter()
.map(|((dimension, _), c)| MobCost {
dimension,
kind: c.kind,
nanos: c.nanos,
mob_ticks: c.mob_ticks,
})
.collect();
out.sort_by(|a, b| b.nanos.cmp(&a.nanos).then_with(|| a.kind.cmp(&b.kind)));
out
}
/// What sampling one mob costs on this machine, in nanoseconds: the report's own overhead.
pub(super) fn sample_cost_nanos() -> f64 {
const N: u32 = 20_000;
let was = SAMPLING.with(Cell::get);
let kept = COSTS.with(|c| std::mem::take(&mut *c.borrow_mut()));
set_sampling(true);
let timer = Instant::now();
for _ in 0..N {
if let Some(started) = start() {
add_kind("calibration", 1, "calibration", started);
}
}
let per = timer.elapsed().as_nanos() as f64 / f64::from(N);
set_sampling(was);
COSTS.with(|c| *c.borrow_mut() = kept);
per
}
#[cfg(test)]
mod tests {
use super::*;
/// A mob type at a made-up address.
fn add(dim: &'static str, (key, id): (usize, &str), started: Instant) {
add_kind(dim, key, id, started);
}
/// Mobs add up by level and type, only on sampled ticks; taking them clears them.
#[test]
fn costs_add_up_by_level_and_type_while_sampling() {
let (zombie, cow) = ((1, "minecraft:zombie"), (2, "minecraft:cow"));
set_sampling(false);
assert!(start().is_none(), "nothing is timed while off");
set_sampling(true);
for _ in 0..3 {
let t = start().expect("sampled");
std::thread::sleep(std::time::Duration::from_millis(1));
add("minecraft:overworld", zombie, t);
}
add("minecraft:overworld", cow, Instant::now());
add("minecraft:the_nether", zombie, Instant::now());
set_sampling(false);
let costs = take();
assert_eq!(costs.len(), 3, "{costs:?}");
assert_eq!(costs[0].kind, "minecraft:zombie");
assert_eq!(costs[0].dimension, "minecraft:overworld");
assert_eq!(costs[0].mob_ticks, 3);
assert!(costs[0].nanos >= 3_000_000, "{}", costs[0].nanos);
let nether = costs
.iter()
.find(|c| c.dimension == "minecraft:the_nether")
.unwrap();
assert_eq!(
(nether.kind.as_str(), nether.mob_ticks),
("minecraft:zombie", 1)
);
assert!(take().is_empty());
}
/// Calibrating leaves what was sampled, and the switch, as they were.
#[test]
fn calibration_keeps_the_samples() {
set_sampling(true);
add("minecraft:overworld", (3, "minecraft:pig"), Instant::now());
let per = sample_cost_nanos();
assert!(per > 0.0 && per < 100_000.0, "{per}");
assert!(start().is_some(), "still sampling");
set_sampling(false);
let costs = take();
assert_eq!(costs.len(), 1);
assert_eq!(costs[0].kind, "minecraft:pig");
}
}
+16 -1
View File
@@ -14,7 +14,7 @@ use super::{
};
use horizon_protocol::{
PROTOCOL_VERSION,
connection::{ConnWriter, Connection},
connection::{ConnWriter, Connection, Counters},
crypto::{offline_uuid, server_hash, uuid_string},
packets::{
Empty,
@@ -243,6 +243,9 @@ pub struct PlayerSender {
pub pending: Arc<AtomicUsize>,
held: Arc<Mutex<Vec<Vec<u8>>>>,
to: To,
/// The connection's bytes on the wire (after compression and encryption), counted by its
/// network thread: `horizon-report`'s network use per player. None for a test's channel.
wire: Option<Arc<Counters>>,
}
#[derive(Clone)]
enum To {
@@ -258,8 +261,19 @@ impl PlayerSender {
pending,
held: Arc::default(),
to: To::Channel(tx),
wire: None,
}
}
/// The bytes received from and sent to the client on the wire so far, where the
/// connection counts them.
pub fn wire_bytes(&self) -> Option<(u64, u64)> {
self.wire.as_ref().map(|c| {
(
c.bytes_in.load(Ordering::Relaxed).max(0) as u64,
c.bytes_out.load(Ordering::Relaxed).max(0) as u64,
)
})
}
/// Whether the client has answered no keep-alive for too long.
pub fn timed_out(&self) -> bool {
matches!(&self.to, To::Pool(_, watch) if watch.timed_out())
@@ -1248,6 +1262,7 @@ fn play(
pending,
held: Arc::default(),
to: To::Pool(outbox.clone(), watch.clone()),
wire: Some(c.reader.counters.clone()),
};
let required_pack = shared
.config
+14 -10
View File
@@ -1825,10 +1825,12 @@ impl Hub {
if ctx.world.light_log.is_none() {
ctx.world.light_log = Some(Default::default());
}
self.sync_world(ctx);
crate::spans::time("lan_sync_world", || self.sync_world(ctx));
crate::spans::time("lan_chunks", || self.update_chunks(ctx));
self.broadcast_light_changes(ctx.world);
self.broadcast_block_changes(ctx.world);
crate::spans::time("lan_block_changes", || {
self.broadcast_light_changes(ctx.world);
self.broadcast_block_changes(ctx.world);
});
// ServerGamePacketListenerImpl.tick: the actions handled since the last tick are
// acknowledged after the block changes they made were sent, so a client's predicted
// blocks settle on the server's states.
@@ -1838,15 +1840,17 @@ impl Hub {
g.send(&C::BlockChangedAck(play::BlockChangedAck { sequence }));
}
}
self.sync_containers(ctx.world);
crate::spans::time("lan_containers", || self.sync_containers(ctx.world));
self.sync_hidden();
self.track_entities(ctx);
for i in 0..self.guests.len() {
if self.here(&self.guests[i]) {
self.sync_player_state(i);
self.sync_effects(i);
crate::spans::time("lan_track_entities", || self.track_entities(ctx));
crate::spans::time("lan_player_state", || {
for i in 0..self.guests.len() {
if self.here(&self.guests[i]) {
self.sync_player_state(i);
self.sync_effects(i);
}
}
}
});
events
}
+26
View File
@@ -26,6 +26,9 @@ pub struct SavedOldWorld {
data: Arc<GeneratorData>,
/// The old chunks of each region file (`SaveDir::old_chunk_bits`).
regions: Mutex<HashMap<(i32, i32), Arc<OnceLock<[u64; 16]>>>>,
/// Chunks asked about one at a time in regions not scanned ([`OldWorld::is_old_chunk`]):
/// per region, the chunks read and the old ones among them.
single: Mutex<HashMap<(i32, i32), ([u64; 16], [u64; 16])>>,
/// Each old chunk's blending data, its border columns calculated.
blending: Mutex<HashMap<(i32, i32), Option<Arc<BlendingData>>>>,
kept_biomes: Mutex<HashMap<(i32, i32), Option<Arc<BiomeChunk<BiomeId>>>>>,
@@ -43,6 +46,7 @@ impl SavedOldWorld {
biomes,
data,
regions: Default::default(),
single: Default::default(),
blending: Default::default(),
kept_biomes: Default::default(),
}
@@ -152,6 +156,28 @@ impl OldWorld for SavedOldWorld {
}
false
}
fn is_old_chunk(&self, x: i32, z: i32) -> bool {
let key = (x >> 5, z >> 5);
let index = ((x & 31) + (z & 31) * 32) as usize;
let (word, bit) = (index / 64, 1u64 << (index % 64));
let scanned = (self.regions.lock().unwrap().get(&key)).and_then(|cell| cell.get().copied());
if let Some(bits) = scanned {
return bits[word] & bit != 0;
}
if let Some((read, old)) = self.single.lock().unwrap().get(&key)
&& read[word] & bit != 0
{
return old[word] & bit != 0;
}
let old = self.save.is_old_chunk(x, z);
let mut single = self.single.lock().unwrap();
let (read, olds) = single.entry(key).or_default();
read[word] |= bit;
if old {
olds[word] |= bit;
}
old
}
fn saved_starts(&self, x: i32, z: i32) -> Vec<horizon_world::worldgen::blending::SavedStart> {
self.save
.raw_chunk(x, z)
+40 -5
View File
@@ -2562,17 +2562,19 @@ impl SaveDir {
let Some(root) = self.read_record_raw("region", pos.x, pos.z) else {
continue;
};
let old = match root.tag.get("DataVersion") {
Some(Tag::Int(version)) => *version < 4997,
_ => true,
} || root.tag.get("blending_data").is_some();
if old {
if record_is_old(&root) {
let index = ((pos.x & 31) + (pos.z & 31) * 32) as usize;
bits[index / 64] |= 1 << (index % 64);
}
}
bits
}
/// Whether the saved chunk at `x, z` is old ([`Self::old_chunk_bits`]'s test), from its
/// record alone: false for a chunk not saved.
pub fn is_old_chunk(&self, x: i32, z: i32) -> bool {
self.read_record_raw("region", x, z)
.is_some_and(|root| record_is_old(&root))
}
/// A saved chunk brought to the current version, for the readers of its
/// fields that `load` does not decode (`blending_data`).
pub fn raw_chunk(&self, x: i32, z: i32) -> Option<NamedTag> {
@@ -5064,6 +5066,15 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
}
}
/// `IOWorker.createOldDataForRegion`'s test of one record: saved before DataVersion 4997, or
/// with `blending_data`.
fn record_is_old(root: &NamedTag) -> bool {
let before_blending = match root.tag.get("DataVersion") {
Some(Tag::Int(version)) => *version < 4997,
_ => true,
};
before_blending || root.tag.get("blending_data").is_some()
}
#[cfg(test)]
mod tests {
use crate::mobs::spawner::SpawnDataExt;
@@ -7440,6 +7451,30 @@ mod tests {
let upgraded = nether.raw_chunk(0, 0).expect("the chunk reads");
assert!(upgraded.tag.get("blending_data").is_none());
assert_ne!(nether.old_chunk_bits(0, 0)[0] & 1, 0);
// One chunk's record tells the same, without the region's other chunks; a chunk this
// version saved, and one not saved, are not old.
let current = Record::Tag(Arc::new(NamedTag {
name: "".into(),
tag: Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("xPos", Tag::Int(1)),
("zPos", Tag::Int(0)),
("yPos", Tag::Int(0)),
("Status", string("minecraft:full")),
("sections", Tag::List(Vec::new())),
]),
}));
super::write_region_records(
&nether.root,
&nether.regions,
"region",
&[((1, 0), &current)],
)
.unwrap();
assert!(nether.is_old_chunk(0, 0));
assert!(!nether.is_old_chunk(1, 0));
assert!(!nether.is_old_chunk(2, 0));
assert_eq!(nether.old_chunk_bits(0, 0)[0] & 0b110, 0);
// A POI record of before 1.14.4 (no DataVersion, read as 1945): its
// sections at the root, which 1946 moved under `Sections`.
let overworld = SaveDir::new(
@@ -404,6 +404,13 @@ pub trait OldWorld: Send + Sync {
/// `WorldGenRegion.isOldChunkAround`: a chunk within `range` of `center`
/// (a square) is old, as far as the storage can tell without reading it.
fn is_old_chunk_around(&self, center: ChunkPos, range: i32) -> bool;
/// Whether the chunk at `x, z` is old, as `StructureCheck.tryLoadFromStorage` finds out:
/// from that chunk's record alone. A structure search asks this of one chunk per spacing
/// cell across thousands of cells; scanning each region it touched, as the blender does,
/// read every chunk an explored world saved there (#8669).
fn is_old_chunk(&self, x: i32, z: i32) -> bool {
self.is_old_chunk_around(ChunkPos::new(x, z), 0)
}
/// The blending data of the old chunk at `x, z`, its border columns
/// calculated, or None for a chunk that is not old.
fn blending(&self, x: i32, z: i32) -> Option<Arc<BlendingData>>;
+67 -1
View File
@@ -2014,7 +2014,7 @@ where
self.pipeline
.old_world
.get()
.is_some_and(|world| world.is_old_chunk_around(chunk, 0))
.is_some_and(|world| world.is_old_chunk(chunk.x, chunk.z))
}
fn saved_starts(&self, chunk: ChunkPos) -> Vec<super::blending::SavedStart> {
self.pipeline
@@ -2455,6 +2455,16 @@ trait SeededPipeline: Send + Sync {
) -> Result<(), String>;
fn forget_structure_starts(&self);
fn ring_positions(&self, worker: &mut WorkerState, set: &str) -> Option<Vec<ChunkPos>>;
/// The structures whose random-spread set may start in `chunk`, each with whether a
/// search's start check lets it start there.
#[cfg(test)]
fn may_start_answers(
&self,
_worker: &mut WorkerState,
_chunk: ChunkPos,
) -> Vec<(String, bool)> {
Vec::new()
}
fn spawn_position(&self) -> Result<[i32; 2], GeneratorError>;
fn region_random(&self, center: ChunkPos) -> Box<dyn super::structure::legacy::PieceRandom>;
fn locate(
@@ -2624,6 +2634,20 @@ where
structures.forget_starts();
}
}
#[cfg(test)]
fn may_start_answers(&self, worker: &mut WorkerState, chunk: ChunkPos) -> Vec<(String, bool)> {
match &self.structures {
Some(structures) => structures.may_start_answers(
&mut StructureQueries {
pipeline: self,
worker,
valid: BTreeSet::new(),
},
chunk,
),
None => Vec::new(),
}
}
fn ring_positions(&self, worker: &mut WorkerState, set: &str) -> Option<Vec<ChunkPos>> {
self.structures.as_ref()?.ring_positions(
&mut StructureQueries {
@@ -3729,6 +3753,10 @@ impl GeneratorWorker {
pub fn forget_structure_starts(&mut self) {
self.pipeline.forget_structure_starts();
}
#[cfg(test)]
fn may_start_answers(&mut self, chunk: ChunkPos) -> Vec<(String, bool)> {
self.pipeline.may_start_answers(&mut self.state, chunk)
}
pub fn stage_timings(&self) -> StageTimings {
self.state.timings
}
@@ -4282,6 +4310,44 @@ mod tests {
assert_eq!(before, after);
}
/// A map's structure search skips a chunk's starts when no wanted structure's start
/// piece and biome fit there (#8669). Over the candidate chunks of every random-spread
/// set around the camp TiesB2007's map went looking from, a skipped structure is never
/// one the chunk's full starts hold, and some are skipped.
#[test]
fn may_start_never_skips_a_start_the_chunk_holds() {
let Some(data) = prepared_data() else {
return;
};
let generator = OverworldGenerator::new(data, -3292718948513116954).expect("generator");
let mut worker = generator.worker();
let (mut held, mut skipped) = (0, 0);
for x in 212..332 {
for z in -481..-361 {
let chunk = ChunkPos::new(x, z);
let checks = worker.may_start_answers(chunk);
if checks.is_empty() {
continue;
}
let starts: Vec<String> = (worker.structure_starts(chunk).iter())
.map(|s| s.name.clone())
.collect();
for (name, may_start) in checks {
if starts.contains(&name) {
assert!(
may_start,
"{name} starts in {chunk:?}, but its check skips it"
);
held += 1;
} else if !may_start {
skipped += 1;
}
}
}
}
assert!(held > 0 && skipped > 0, "held {held}, skipped {skipped}");
}
/// Generators of one seed and dimension share their ring positions;
/// another seed or generator gets its own, and old entries go.
#[test]
@@ -8,7 +8,7 @@ use super::{
jigsaw::PoolPiece,
start::{
Piece, Rings, StartContext, StructureRegistry, StructureStart, beardifier,
create_starts, try_generate,
create_starts, may_start, try_generate,
},
stronghold::RingSearch,
},
@@ -379,6 +379,39 @@ impl StructureState {
.any(|(_, search)| search.may_land_near(chunk.x, chunk.z, self.zone_reach));
if near { self.rings(context) } else { &NONE }
}
/// The structures of the random-spread sets whose start chunk is `chunk`, each with
/// [`may_start`]'s answer, for tests.
#[cfg(test)]
pub(super) fn may_start_answers(
&self,
context: &mut impl super::structure::start::StartContext,
chunk: ChunkPos,
) -> Vec<(String, bool)> {
let seed = context.seed();
let rings = self.rings_for(context, chunk).clone();
let mut checks = Vec::new();
for &si in &self.possible_sets {
let set = &self.registry.sets[si];
if set.spacing().is_none()
|| StructureRegistry::potential_chunk(set, seed, chunk.x, chunk.z) != Some(chunk)
|| !(self.registry).is_structure_chunk(set, seed, chunk.x, chunk.z, &rings)
{
continue;
}
for &(s, _) in &set.structures {
let may = may_start(
&self.registry,
&self.resources,
&self.bindings,
context,
s,
chunk,
);
checks.push((self.registry.structures[s].name.clone(), may));
}
}
checks
}
/// `StructureRegistry::needs_dimension_origin`: whether any set is placed
/// by the dimension's origin chunk alone.
pub(super) fn needs_dimension_origin(&self) -> bool {
@@ -602,9 +635,9 @@ impl StructureState {
/// its locate position (the start chunk's corner at y 0). `skip` says a
/// start (structure name, its chunk) can't be referenced again
/// (`StructureStart.canBeReferenced`): the search goes on past it.
pub(super) fn locate(
pub(super) fn locate<C: super::structure::start::StartContext>(
&self,
context: &mut impl super::structure::start::StartContext,
context: &mut C,
names: &[String],
from: [i32; 3],
radius: i32,
@@ -618,7 +651,30 @@ impl StructureState {
dx * dx + dz * dz
};
let mut best: Option<(f64, String, [i32; 3])> = None;
let found_at = |this: &Self, context: &mut _, chunk: ChunkPos| -> Option<String> {
let wanted_structures: Vec<usize> = (0..self.registry.structures.len())
.filter(|&s| wanted(s))
.collect();
let found_at = |this: &Self, context: &mut C, chunk: ChunkPos| -> Option<String> {
// StructureCheck.checkStart: a chunk with no starts built or saved yet is first
// asked whether a wanted structure could start there (canCreateStructure, a
// jigsaw's start piece and its biome), and only then are all its starts built,
// layouts included. A map's search for a camp of a rarer biome built thousands of
// chunks' starts on the server thread, seconds of it (#8669).
if this.starts.get((chunk.x, chunk.z)).is_none()
&& !context.is_old_chunk(chunk)
&& !wanted_structures.iter().any(|&s| {
may_start(
&this.registry,
&this.resources,
&this.bindings,
context,
s,
chunk,
)
})
{
return None;
}
this.starts_at(context, chunk)
.iter()
.find(|s| wanted(s.structure) && !skip(&s.name, chunk))
@@ -1075,6 +1075,37 @@ pub fn assemble_drawing(
random: &mut impl RandomSource,
position: [i32; 3],
) -> Option<([i32; 3], Vec<PoolPiece>)> {
let start = place_start(
draws, config, context, pools, resources, bindings, random, position,
)?;
Some(lay_out_children(
draws, start, config, context, pools, resources, bindings, random,
))
}
/// A jigsaw's start piece in place ([`place_start`]), its children not laid out yet: vanilla's
/// `Structure.GenerationStub`, whose position the structure's biome is checked at before
/// `JigsawPlacement.addPieces` lays the rest out.
pub struct PlacedStart {
/// The start position (`[centre x, start y, centre z]`).
pub position: [i32; 3],
center: PoolPiece,
}
/// The first half of [`assemble_drawing`]: the start piece drawn, rotated, projected to the
/// heightmap and checked against the height limits. None when the start is invalid. The
/// draws it makes come first in `random`, as in `assemble_drawing`.
#[allow(clippy::too_many_arguments)]
pub fn place_start(
draws: Draws,
config: &JigsawConfig<'_>,
context: &mut impl JigsawContext,
pools: &Pools,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
random: &mut impl RandomSource,
position: [i32; 3],
) -> Option<PlacedStart> {
let alias = |name: &str| {
config
.aliases
@@ -1144,6 +1175,29 @@ pub fn assemble_drawing(
return None;
}
let cy = bottom + local[1];
Some(PlacedStart {
position: [cx, cy, cz],
center,
})
}
/// The second half of [`assemble_drawing`]: the children of a [`place_start`]ed start laid out
/// to `config.max_depth`, with the same `random`. Returns the start position and every piece.
#[allow(clippy::too_many_arguments)]
pub fn lay_out_children(
draws: Draws,
start: PlacedStart,
config: &JigsawConfig<'_>,
context: &mut impl JigsawContext,
pools: &Pools,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
random: &mut impl RandomSource,
) -> ([i32; 3], Vec<PoolPiece>) {
let PlacedStart {
position: [cx, cy, cz],
center,
} = start;
let mut pieces = vec![center];
if config.max_depth > 0 {
let [h, v] = config.max_distance;
@@ -1164,7 +1218,7 @@ pub fn assemble_drawing(
container,
);
}
Some(([cx, cy, cz], pieces))
([cx, cy, cz], pieces)
}
/// The inner JigsawPlacement.addPieces: lays out the children of
@@ -3,7 +3,8 @@
use super::{
BoundingBox, StructureResources,
jigsaw::{
JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, assemble_drawing,
JigsawConfig, JigsawContext, PlaceFeature, PoolPiece, Pools, Projection, lay_out_children,
place_start,
},
legacy::{LegacyPiece, PieceContext},
};
@@ -1018,7 +1019,53 @@ pub(crate) fn try_generate(
chunk: ChunkPos,
any_biome: bool,
) -> Option<StructureStart> {
match generate(
registry, resources, bindings, context, structure, chunk, any_biome, false,
)? {
Generated::Start(start) => Some(start),
Generated::Stub => None,
}
}
/// `StructureCheck.canCreateStructure`: whether `structure` may start in `chunk`, by
/// `Structure.findValidGenerationPoint` alone. A jigsaw's start piece is placed and its biome
/// checked; its children are not laid out. Other structures answer true: their starts are
/// cheap to build. False only where [`try_generate`] can't start it, so a search may skip
/// the chunk's full starts then (#8669).
pub(crate) fn may_start(
registry: &StructureRegistry,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
context: &mut impl StartContext,
structure: usize,
chunk: ChunkPos,
) -> bool {
generate(
registry, resources, bindings, context, structure, chunk, false, true,
)
.is_some()
}
/// What [`generate`] made: the start, or with `stub_only` only that it may start.
enum Generated {
Start(StructureStart),
Stub,
}
#[allow(clippy::too_many_arguments)]
fn generate(
registry: &StructureRegistry,
resources: &StructureResources,
bindings: &FeatureBlockBindings<'_>,
context: &mut impl StartContext,
structure: usize,
chunk: ChunkPos,
any_biome: bool,
stub_only: bool,
) -> Option<Generated> {
let def = &registry.structures[structure];
let vanilla_jigsaw =
matches!(&def.kind, StructureKind::Jigsaw(jigsaw) if jigsaw.piece_limited.is_none());
if stub_only && !vanilla_jigsaw {
return Some(Generated::Stub);
}
context.set_valid_biomes((!any_biome).then_some(&def.biomes));
let seed = context.seed();
let mut random = WorldgenRandom::new(LegacyRandom::new(0));
@@ -1061,16 +1108,38 @@ pub(crate) fn try_generate(
chunk,
start,
)?,
None => assemble_drawing(
jigsaw.draws,
&config,
context,
&registry.pools,
resources,
bindings,
&mut random,
start,
)?,
None => {
let placed = place_start(
jigsaw.draws,
&config,
context,
&registry.pools,
resources,
bindings,
&mut random,
start,
)?;
// Structure.findValidGenerationPoint checks the stub's biome before its
// GenerationStub lays the pieces out: a start in another biome costs its
// start piece, not its whole layout (an ancient city's, a camp's).
let quart = placed.position.map(|v| v >> 2);
if !any_biome && !def.biomes.contains(&context.noise_biome(quart)) {
return None;
}
if stub_only {
return Some(Generated::Stub);
}
lay_out_children(
jigsaw.draws,
placed,
&config,
context,
&registry.pools,
resources,
bindings,
&mut random,
)
}
};
// Vanilla's JigsawPlacement keeps no piece at size 0; the
// piece-limited assembler keeps the start piece.
@@ -1331,7 +1400,9 @@ pub(crate) fn try_generate(
if (!any_biome && !def.biomes.contains(&context.noise_biome(quart))) || pieces.is_empty() {
return None;
}
Some(StructureStart::new(structure, def, chunk, pieces))
Some(Generated::Start(StructureStart::new(
structure, def, chunk, pieces,
)))
}
impl StructureStart {
/// A start of `def` (registry index `structure`) from its pieces, at
+4 -2
View File
@@ -22,9 +22,11 @@ tunes it. LAN worlds and single player don't filter.
## Muting by hand
Operators of level 3 (`/ban`'s level) mute players themselves; the console takes the same words without the
slash. These are Horizon extras, and exist only on servers that filter chat.
slash. These are Horizon extras, and exist only on servers that filter chat. They are in the command tree of
operators of level 3 and above, so their clients complete them; other players don't see them.
- `/mute <player> [reason]`: muted until `/unmute`.
- `/mute <player> [duration] [reason]`: muted until `/unmute`, or for the duration when the word after the name
is one (`/mute Steve 1h spam`).
- `/tempmute <player> <duration> [reason]`: muted for a duration such as `30s`, `10m`, `2h`, `1d` or `1w`.
- `/mutelist`: every mute in force, the filter's and the operators', with who muted, until when and why.
- `/unmute <player>`: lifts either kind.
+10 -3
View File
@@ -317,7 +317,9 @@ is online is kicked with the reason and the time the ban ends, and the same mess
try to join. A player who is already banned (and whose ban has not ended) is left as they are: `pardon` first to
change a ban. The player may be offline if the server has seen them. The console logs it as
`[tempban] <operator> banned <player> for <duration>`, and the event stream (`GET /api/v1/events`) gets a `ban`
event with `reason`, `by` and `until`. It is not in the command tree, so it has no completions, like `/mute`.
event with `reason`, `by` and `until`. `tempbanlist` lists the bans that end by themselves, soonest first, with
who banned, the time left, the end and the reason. Both are in operators' command tree (level 3 and above), so
their clients complete them, as they do the [mute commands](chat-filter.md#muting-by-hand).
To generate the world around spawn ahead of the players, Horizon has its own `pregen` command, off until
`server.toml` has `horizon-pregen = true` under `[properties]`: `horizon-server cmd pregen start 2500`
@@ -341,8 +343,13 @@ Tiles are sampled again for each player who asks (there is no cache yet). Vanill
then prints a short summary and saves it in `reports/`: `perf-<time>.txt` and `perf-<time>.json`. They say where
the ticks' time went, phase by phase (mobs, block ticks, block entities, the network and the chunks it
sends, saving...), the slowest ticks, how many entities of each type, chunks, players, memory, CPU, and the
settings that matter (view and simulation distance, max players). They hold no names, UUIDs or addresses, so you
can attach them to an issue as they are. On the console it is `horizon-report [SECONDS]`.
settings that matter (view and simulation distance, max players). Since format 2 they also give each dimension's
share of the tick (`level_*`), what each mob type costs per dimension (one tick in four times every mob: ms a
tick, mobs, µs a mob), the chunk workers' CPU by job (load, generate, decorate, finish, light), the network bytes
a player sends and receives, and what recording cost the tick (measured, usually well under 1 % of MSPT). They
hold no names, UUIDs or addresses, so you can attach them to an issue as they are. On the console it is
`horizon-report [SECONDS]`; `horizon-report stop` writes the report now and `horizon-report status` says how far
it is. Nothing restarts: the report runs beside the game.
Between reports, the log keeps the spikes: a tick slower than 250 ms writes a `Horizon: slow tick` line with
its phases (`HORIZON_SPIKES=MS` in the engine's environment sets another threshold), and a server more than