Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ dependencies {
val fapiVersion = libs.versions.fabric.api.get()
modInclude(fabricApi.module("fabric-api-base", fapiVersion))
modInclude(fabricApi.module("fabric-resource-loader-v1", fapiVersion))
modInclude(fabricApi.module("fabric-networking-api-v1", fapiVersion))

// Compat fixes
compileOnly(fabricApi.module("fabric-renderer-indigo", fapiVersion))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@

package meteordevelopment.meteorclient.commands.commands;

import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.suggestion.Suggestion;
import com.mojang.brigadier.suggestion.Suggestions;
import meteordevelopment.meteorclient.MeteorClient;
import meteordevelopment.meteorclient.commands.Command;
import meteordevelopment.meteorclient.events.game.GameJoinedEvent;
import meteordevelopment.meteorclient.events.game.GameLeftEvent;
import meteordevelopment.meteorclient.events.packets.PacketEvent;
import meteordevelopment.meteorclient.events.world.TickEvent;
import meteordevelopment.meteorclient.mixin.ClientPacketListenerAccessor;
import meteordevelopment.meteorclient.utils.Utils;
import meteordevelopment.meteorclient.utils.network.ServerUptime;
import meteordevelopment.meteorclient.utils.world.TickRate;
import meteordevelopment.orbit.EventHandler;
import meteordevelopment.orbit.EventPriority;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.ChatFormatting;
import net.minecraft.client.multiplayer.ClientSuggestionProvider;
import net.minecraft.client.multiplayer.ServerData;
Expand All @@ -25,6 +32,7 @@
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.HoverEvent;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.protocol.common.ClientboundKeepAlivePacket;
import net.minecraft.network.protocol.game.ClientboundCommandSuggestionsPacket;
import net.minecraft.network.protocol.game.ClientboundCommandsPacket;
import net.minecraft.network.protocol.game.ServerboundCommandSuggestionPacket;
Expand All @@ -39,6 +47,7 @@
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import java.util.Set;

Expand All @@ -51,7 +60,8 @@ public class ServerCommand extends Command {
private final List<String> plugins = new ArrayList<>();
private final List<String> commandTreePlugins = new ArrayList<>();
private static final Random RANDOM = new Random();

private final ServerUptime uptime = new ServerUptime();
private volatile int connectionGeneration;

public ServerCommand() {
super("server", "Prints server information");
Expand Down Expand Up @@ -91,6 +101,26 @@ public void build(LiteralArgumentBuilder<ClientSuggestionProvider> builder) {
info("Current TPS: %s%.2f(default).", color, tps);
return SINGLE_SUCCESS;
}));

for (String section : new String[]{"uptime", "ping", "players"}) {
builder.then(literal(section).executes(_ -> {
if (!Utils.canUpdate()) error("Join a server first.");
else switch (section) {
case "uptime" -> printUptime();
case "ping" -> printPing();
case "players" -> printPlayers();
}
return SINGLE_SUCCESS;
}));
}

builder.then(literal("channels").executes(_ -> {
printChannels(1);
return SINGLE_SUCCESS;
}).then(argument("page", IntegerArgumentType.integer(1)).executes(context -> {
printChannels(IntegerArgumentType.getInteger(context, "page"));
return SINGLE_SUCCESS;
})));
}

private void basicInfo() {
Expand Down Expand Up @@ -158,6 +188,90 @@ private void basicInfo() {
);
info("Day: %d", mc.level.getDefaultClockTime() / 24000L);
info("Permission level: %s", formatPerms());
printUptime();
printPing();
printPlayers();
}

private void printUptime() {
if (mc.hasSingleplayerServer()) {
info("Uptime: unavailable (singleplayer).");
return;
}
long millis = uptime.getUptimeMillis(System.nanoTime());
if (millis >= 0) {
long seconds = millis / 1000;
String duration = String.format(Locale.ROOT, "%dd %02dh %02dm %02ds", seconds / 86400, seconds / 3600 % 24, seconds / 60 % 60, seconds % 60);
info("Uptime (estimated): %s.", duration);
} else switch (uptime.getClockType()) {
case Collecting -> info("Uptime: collecting (%d/3).", uptime.getSamples());
case Epoch, Unsupported -> info("Uptime: unavailable (requires a compatible server clock).");
default -> info("Uptime: unavailable (stale data).");
}
}

private void printPing() {
var connection = mc.getConnection();
var player = connection.getPlayerInfo(mc.player.getUUID());
if (player == null || player.getLatency() < 0) info("Ping: unavailable.");
else info("Ping: %d ms.", player.getLatency());

ServerData server = connection.getServerData();
if (server != null && server.players != null && server.ping >= 0) info("List ping: %d ms (cached).", server.ping);
}

private void printPlayers() {
var connection = mc.getConnection();
info("Players: %d known, %d listed.", connection.getOnlinePlayers().size(), connection.getListedOnlinePlayers().size());
ServerData server = connection.getServerData();
if (server != null && server.players != null) info("Players (cached): %d/%d.", server.players.online(), server.players.max());
info("Online mode: %s.", connection.onlineMode());
}

private void printChannels(int page) {
if (!Utils.canUpdate()) {
error("Join a server first.");
return;
}
List<String> channels = ClientPlayNetworking.getSendable().stream().map(Object::toString).sorted().toList();
if (channels.isEmpty()) {
info("Channels: none.");
return;
}
int pages = (channels.size() - 1) / 25 + 1;
if (page > pages) {
error("Page out of range; choose 1-%d.", pages);
return;
}
int start = (page - 1) * 25;
info("Channels (%d, page %d/%d): %s", channels.size(), page, pages,
String.join(", ", channels.subList(start, Math.min(start + 25, channels.size()))));
if (page < pages) info("Next page: %s", toString("channels", Integer.toString(page + 1)));
}

@EventHandler
private void onJoin(GameJoinedEvent event) {
connectionGeneration++;
uptime.reset();
}

@EventHandler
private void onLeave(GameLeftEvent event) {
connectionGeneration++;
uptime.reset();
}

@EventHandler(priority = EventPriority.LOWEST)
private void onKeepalive(PacketEvent.Receive event) {
if (event.isCancelled() || !(event.packet instanceof ClientboundKeepAlivePacket packet)) return;
int generation = connectionGeneration;
long receivedNs = System.nanoTime();
var connection = event.connection;
mc.execute(() -> {
if (generation == connectionGeneration && mc.getConnection() != null && connection == mc.getConnection().getConnection()) {
uptime.observe(packet.getId(), receivedNs);
}
});
}

public String formatPerms() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client).
* Copyright (c) Meteor Development.
* Adapted from THM Addons (https://github.com/Leonn170709/THM-Addons).
* Copyright (c) THM Addons contributors.
*/

package meteordevelopment.meteorclient.utils.network;

public class ServerUptime {
public enum ClockType { Collecting, Milliseconds, Nanoseconds, Epoch, Unsupported }

private static final long MAX_UPTIME_MS = 30L * 366 * 24 * 60 * 60 * 1000;
private long lastId, lastReceivedNs;
private int samples, matchingIntervals;
private ClockType candidate = ClockType.Collecting;

public void reset() {
lastId = lastReceivedNs = 0;
samples = matchingIntervals = 0;
candidate = ClockType.Collecting;
}

public void observe(long id, long receivedNs) {
if (samples > 0) {
double elapsedMs = (receivedNs - lastReceivedNs) / 1_000_000.0;
double delta = (double) id - lastId;
ClockType next = ClockType.Unsupported;
if (id > 0 && lastId > 0 && elapsedMs >= 100 && delta > 0) {
if (matches(delta, elapsedMs)) next = id < MAX_UPTIME_MS ? ClockType.Milliseconds : ClockType.Epoch;
else if (matches(delta / 1_000_000.0, elapsedMs) && id / 1_000_000 < MAX_UPTIME_MS) next = ClockType.Nanoseconds;
}
matchingIntervals = next == candidate ? Math.min(2, matchingIntervals + 1) : 1;
candidate = next;
}
lastId = id;
lastReceivedNs = receivedNs;
samples = Math.min(3, samples + 1);
}

private static boolean matches(double deltaMs, double elapsedMs) {
return Math.abs(deltaMs - elapsedMs) <= Math.max(25, elapsedMs * .2);
}

public ClockType getClockType() {
return matchingIntervals >= 2 ? candidate : ClockType.Collecting;
}

public int getSamples() {
return samples;
}

public long getUptimeMillis(long nowNs) {
long age = (nowNs - lastReceivedNs) / 1_000_000;
if (samples == 0 || age < 0 || age > 60_000) return -1;
// A monotonic clock's origin is JVM/platform-dependent.
return switch (getClockType()) {
case Milliseconds -> lastId + age;
case Nanoseconds -> lastId / 1_000_000 + age;
default -> -1;
};
}
}
66 changes: 66 additions & 0 deletions tools/tests/ServerUptimeTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client).
* Copyright (c) Meteor Development.
*/

import meteordevelopment.meteorclient.utils.network.ServerUptime;

// Run after building: java -ea -cp build/classes/java/main tools/tests/ServerUptimeTest.java
public class ServerUptimeTest {
private static final long SECOND = 1_000_000_000L;

public static void main(String[] args) {
var clock = new ServerUptime();
assert clock.getUptimeMillis(0) == -1;
clock.observe(86_400_000, 0);
clock.observe(86_415_000, 15 * SECOND);
assert clock.getUptimeMillis(15 * SECOND) == -1 : "Require three consistent keepalives.";
clock.observe(86_430_000, 30 * SECOND);
assert clock.getClockType() == ServerUptime.ClockType.Milliseconds;
assert clock.getUptimeMillis(35 * SECOND) == 86_435_000;
assert clock.getUptimeMillis(91 * SECOND) == -1 : "Reject stale samples.";
assert clock.getUptimeMillis(29 * SECOND) == -1;

var nanos = samples(86400 * SECOND, 86415 * SECOND, 86430 * SECOND);
assert nanos.getClockType() == ServerUptime.ClockType.Nanoseconds;
assert nanos.getUptimeMillis(35 * SECOND) == 86_435_000;
var epoch = samples(1_780_000_000_000L, 1_780_000_015_000L, 1_780_000_030_000L);
assert epoch.getClockType() == ServerUptime.ClockType.Epoch;
assert epoch.getUptimeMillis(30 * SECOND) == -1;

for (long[] ids : new long[][]{
{1, 2, 3}, {500000, 10, 987654321}, {10000, 10000, 10000}, {-30000, -15000, -1},
{Long.MIN_VALUE, 1, Long.MAX_VALUE},
{1_780_000_000_000_000_000L, 1_780_000_015_000_000_000L, 1_780_000_030_000_000_000L}
}) {
assert samples(ids).getUptimeMillis(30 * SECOND) == -1 : "Reject unsupported IDs.";
}

var jitter = new ServerUptime();
jitter.observe(100000, 0);
jitter.observe(115000, 14 * SECOND);
jitter.observe(130000, 31 * SECOND);
assert jitter.getUptimeMillis(31 * SECOND) == 130000;
var burst = new ServerUptime();
for (int i = 0; i < 3; i++) burst.observe(100000 + 15000 * i, i * SECOND / 1000);
assert burst.getUptimeMillis(SECOND) == -1 : "Reject packet bursts.";

clock.observe(1000, 45 * SECOND);
assert clock.getUptimeMillis(45 * SECOND) == -1;
clock.observe(16000, 60 * SECOND);
assert clock.getUptimeMillis(60 * SECOND) == -1;
clock.observe(31000, 75 * SECOND);
assert clock.getUptimeMillis(75 * SECOND) == 31000 : "Recover after clock changes.";
clock.reset();
assert clock.getSamples() == 0 && clock.getUptimeMillis(75 * SECOND) == -1;
for (int i = 0; i < 100000; i++) clock.observe(100000L + 15000L * i, i * 15 * SECOND);
assert clock.getSamples() == 3 : "Keep sample state bounded.";
System.out.println("ServerUptime checks passed.");
}

private static ServerUptime samples(long... ids) {
var clock = new ServerUptime();
for (int i = 0; i < ids.length; i++) clock.observe(ids[i], i * 15 * SECOND);
return clock;
}
}
Loading