Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 660a170f61 Count packets per second on F3 and show the server line
The F3 tps line printed a literal "0 tx, 0 rx" and was hidden on servers.
Horizon now averages the connection's packet counts once a second as
Connection.tick does, shows them on the integrated line when single player
runs through its server connection, and shows vanilla's "<brand>" server
line on a remote server with the brand the server sent.

Fixes #564

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:23:56 +02:00
4 changed files with 111 additions and 15 deletions

No files matched your search

+44
View File
@@ -77,6 +77,37 @@ pub struct DebugCharts {
previous_tick: Option<Instant>,
bytes_seen: Option<i64>,
}
/// `Connection.getAverageSentPackets` / `getAverageReceivedPackets`: once a second
/// (`Connection.tick`, every 20 ticks from the first) each average moves a quarter of
/// the way to the packets counted since the last time.
#[derive(Debug, Default, Clone)]
pub struct PacketRates {
/// The connection's totals at the last update.
seen: Option<(i64, i64)>,
ticks: u32,
pub sent: f32,
pub received: f32,
}
impl PacketRates {
/// One client tick, with the connection's sent and received packet totals.
pub fn tick(&mut self, sent: i64, received: i64) {
let tick = self.ticks;
self.ticks = self.ticks.wrapping_add(1);
if !tick.is_multiple_of(20) {
return;
}
let (last_sent, last_received) = self.seen.unwrap_or((0, 0));
// Mth.lerp(0.75, count, average).
let lerp = |count: i64, average: f32| {
let count = (count.max(0)) as f32;
count + 0.75 * (average - count)
};
self.sent = lerp(sent - last_sent, self.sent);
self.received = lerp(received - last_received, self.received);
self.seen = Some((sent, received));
}
}
impl DebugCharts {
/// `toggleFpsCharts`; `overlay` is the debug overlay's visibility.
pub fn toggle_fps(&mut self, overlay: &mut bool) {
@@ -480,6 +511,19 @@ fn shaded(out: &mut Vec<GuiDraw>, font: &Font, s: &str, x: i32, y: i32) {
#[cfg(test)]
mod tests {
#[test]
fn packet_rates_move_a_quarter_of_the_way_each_second() {
let mut rates = super::PacketRates::default();
// The first tick already counts (Connection.tick's tickCount++ % 20 == 0).
rates.tick(40, 400);
assert_eq!((rates.sent, rates.received), (10.0, 100.0));
for _ in 1..20 {
rates.tick(1000, 1000);
assert_eq!((rates.sent, rates.received), (10.0, 100.0));
}
rates.tick(80, 400);
assert_eq!((rates.sent, rates.received), (17.5, 75.0));
}
use super::*;
#[test]
+36 -12
View File
@@ -187,20 +187,25 @@ impl Game<'_> {
/// they show, `BandwidthDebugMonitor.tick` always.
pub(super) fn tick_debug_charts(&mut self) {
let shown = self.debug && self.debug_charts.network;
let Some(r) = &self.remote else {
let Some(r) = &mut self.remote else {
// The integrated server answers within the tick.
if shown {
self.debug_charts.ping.log(0);
}
return;
};
let received = r
.session
.counters
.lock()
.unwrap()
.as_ref()
.map(|c| c.bytes_in.load(std::sync::atomic::Ordering::Relaxed));
let totals = r.session.counters.lock().unwrap().as_ref().map(|c| {
use std::sync::atomic::Ordering::Relaxed;
(
c.bytes_in.load(Relaxed),
c.sent.load(Relaxed),
c.received.load(Relaxed),
)
});
let received = totals.map(|(bytes, sent, packets)| {
r.packet_rates.tick(sent, packets);
bytes
});
if let Some(total) = received {
self.debug_charts.log_bandwidth(total);
}
@@ -403,8 +408,27 @@ impl Game<'_> {
],
);
}
// tps: DebugEntryTps for the integrated server (it counts no packets).
if self.player.is_some() && self.remote.is_none() {
// tps: DebugEntryTps. Single player without its server thread has no connection, so
// it counts no packets.
let rates = self.remote.as_ref().map_or((0.0, 0.0), |r| {
(r.packet_rates.sent, r.packet_rates.received)
});
if let Some(r) = self.remote.as_ref().filter(|r| !r.local) {
let status = if self.clock.stepping() {
" (frozen - stepping)"
} else if self.clock.frozen() {
" (frozen)"
} else {
""
};
let brand = r.session.brand.lock().unwrap().clone();
d.line(format!(
"\"{}\" server{status}, {:.0} tx, {:.0} rx",
brand.as_deref().unwrap_or("null"),
rates.0,
rates.1
));
} else if self.player.is_some() || self.remote.is_some() {
let (target, status) = if self.clock.sprinting() {
("-".to_owned(), " (sprinting)")
} else if self.clock.stepping() {
@@ -418,8 +442,8 @@ impl Game<'_> {
(format!("{:.1}", 1000.0 / self.clock.rate()), "")
};
d.line(format!(
"Integrated server @ {:.1}/{target} ms{status}, 0 tx, 0 rx",
self.debug_charts.smoothed_tick_ms
"Integrated server @ {:.1}/{target} ms{status}, {:.0} tx, {:.0} rx",
self.debug_charts.smoothed_tick_ms, rates.0, rates.1
));
}
let (left, right) = d.columns(&self.debug_chart_lines());
+7
View File
@@ -88,6 +88,10 @@ pub(super) struct Remote {
was_sprinting: bool,
carried: Option<usize>,
loaded_sent: bool,
/// Single player's integrated server, joined through memory.
pub local: bool,
/// The packets per second F3 shows.
pub packet_rates: horizon_client::debug_chart::PacketRates,
/// Open container: (window id, menu name).
menu: Option<(i32, String)>,
/// The open menu's title as the server named it.
@@ -198,6 +202,7 @@ impl Game<'_> {
// A signed-in account always plays under its own name.
let auth = horizon_client::auth::current();
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
let is_local = local.is_some();
let session = match local {
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
None => {
@@ -271,6 +276,8 @@ impl Game<'_> {
was_sprinting: false,
carried: None,
loaded_sent: false,
local: is_local,
packet_rates: Default::default(),
menu: None,
menu_title: None,
state_id: 0,
+24 -3
View File
@@ -56,6 +56,8 @@ pub struct Session {
pub address: String,
pub name: String,
pub counters: Arc<Mutex<Option<Arc<Counters>>>>,
/// The server's minecraft:brand payload (ClientCommonPacketListenerImpl.serverBrand).
pub brand: Arc<Mutex<Option<String>>>,
/// HORIZON_NET_DEBUG=1 logs every packet sent from the game thread.
debug: bool,
}
@@ -90,7 +92,9 @@ impl Session {
let (send, events) = channel();
let writer = Arc::new(Mutex::new(None));
let counters = Arc::new(Mutex::new(None));
let (thread_writer, thread_counters) = (writer.clone(), counters.clone());
let brand = Arc::new(Mutex::new(None));
let (thread_writer, thread_counters, thread_brand) =
(writer.clone(), counters.clone(), brand.clone());
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
let thread = std::thread::Builder::new()
.name("horizon-net".into())
@@ -105,6 +109,7 @@ impl Session {
&send,
&thread_writer,
&thread_counters,
&thread_brand,
);
let from_server = result.is_ok();
let reason = result.unwrap_or_else(|e| e);
@@ -125,6 +130,7 @@ impl Session {
address: address.to_owned(),
name: name.to_owned(),
counters,
brand,
debug,
}
}
@@ -251,6 +257,7 @@ fn run(
send: &Sender<Event>,
shared_writer: &Mutex<Option<ConnWriter>>,
shared_counters: &Mutex<Option<Arc<Counters>>>,
server_brand: &Mutex<Option<String>>,
) -> Result<String, String> {
let (host, port, mut c) = if let Some(pipe) = local {
(address.to_owned(), 0, Connection::local(pipe))
@@ -374,7 +381,7 @@ fn run(
.map_err(e)?;
let (mut reader, writer) = c.split();
*shared_writer.lock().unwrap() = Some(writer);
let registries = configure(&mut reader, shared_writer, lang)?;
let registries = configure(&mut reader, shared_writer, lang, server_brand)?;
let _ = send.send(Event::Joined {
registries: Arc::new(registries),
uuid: profile.uuid,
@@ -413,10 +420,13 @@ fn run(
}
play::Clientbound::StartConfiguration(_) => {
reply(play::Serverbound::ConfigurationAcknowledged(Empty));
let registries = configure(&mut reader, shared_writer, lang)?;
let registries = configure(&mut reader, shared_writer, lang, server_brand)?;
let _ = send.send(Event::Reconfigured(Arc::new(registries)));
}
play::Clientbound::Disconnect(d) => return Ok(plain(&d.reason, lang)),
play::Clientbound::CustomPayload(p) if p.channel == "minecraft:brand" => {
set_brand(server_brand, &p.data);
}
other => {
if send.send(Event::Packet(Box::new(other))).is_err() {
return Ok("Disconnected".into());
@@ -432,6 +442,7 @@ fn configure(
reader: &mut ConnReader,
writer: &Mutex<Option<ConnWriter>>,
lang: &Language,
server_brand: &Mutex<Option<String>>,
) -> Result<Dynamic, String> {
let mut registries = Dynamic::default();
let reply = |p: cfg::Serverbound| -> Result<(), String> {
@@ -452,6 +463,9 @@ fn configure(
}))?
}
cfg::Clientbound::RegistryData(r) => registries.add(r),
cfg::Clientbound::CustomPayload(p) if p.channel == "minecraft:brand" => {
set_brand(server_brand, &p.data);
}
cfg::Clientbound::CodeOfConduct(_) => {
reply(cfg::Serverbound::AcceptCodeOfConduct(Empty))?
}
@@ -477,6 +491,13 @@ fn configure(
}
}
/// BrandPayload: the name the server gives itself, shown on F3.
fn set_brand(shared: &Mutex<Option<String>>, data: &[u8]) {
if let Ok(brand) = horizon_protocol::buf::Reader::new(data).string(32767) {
*shared.lock().unwrap() = Some(brand);
}
}
/// The minecraft:brand payload: a string naming the client.
fn brand() -> Vec<u8> {
let mut w = horizon_protocol::buf::Writer::new();