From b64b1f17db03f4baf05ccc6fa1b4d123352709e7 Mon Sep 17 00:00:00 2001 From: itsRevela Date: Fri, 24 Apr 2026 21:31:06 -0500 Subject: [PATCH] perf(fourkit): server gc, fast-path event dispatch, tps probe --- Minecraft.Server.FourKit/EventDispatcher.cs | 100 ++++++----- Minecraft.Server.FourKit/FourKit.cs | 59 ++++++- .../FourKitHost.Callbacks.cs | 28 +++ .../Minecraft.Server.FourKit.csproj | 2 + Minecraft.Server.FourKit/NativeBridge.cs | 19 ++ Minecraft.Server/FourKitBridge.cpp | 22 ++- Minecraft.Server/FourKitNatives.cpp | 21 +++ Minecraft.Server/FourKitNatives.h | 13 ++ .../FourKitTestPlugin/FourKitTestPlugin.cs | 164 +++++++++++++++++- tools/stress-test/stress_test.py | 101 +++++++++-- tools/stress-test/test_fourkit_chunk.bat | 14 ++ 11 files changed, 486 insertions(+), 57 deletions(-) create mode 100644 tools/stress-test/test_fourkit_chunk.bat diff --git a/Minecraft.Server.FourKit/EventDispatcher.cs b/Minecraft.Server.FourKit/EventDispatcher.cs index 9c5bc2c1..4938a541 100644 --- a/Minecraft.Server.FourKit/EventDispatcher.cs +++ b/Minecraft.Server.FourKit/EventDispatcher.cs @@ -22,60 +22,80 @@ internal sealed class EventDispatcher public int CompareTo(RegisteredHandler other) => Priority.CompareTo(other.Priority); } - private readonly Dictionary> _handlers = new(); - private readonly object _lock = new(); + // Snapshot-on-write: writers swap _handlers atomically; Fire reads it lock-free. + private volatile Dictionary _handlers = new(); + private readonly object _writeLock = new(); + + // Fired when an event type gains its first handler. + internal Action? OnSubscriptionChanged; + public void Register(Listener listener) { var methods = listener.GetType().GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - lock (_lock) + List<(Type eventType, RegisteredHandler handler)>? pending = null; + foreach (var method in methods) { - foreach (var method in methods) + var attr = method.GetCustomAttribute(); + if (attr == null) + continue; + + var parameters = method.GetParameters(); + if (parameters.Length != 1) { - var attr = method.GetCustomAttribute(); - if (attr == null) - continue; - - var parameters = method.GetParameters(); - if (parameters.Length != 1) - { - Console.WriteLine($"[FourKit] Warning: @EventHandler method {method.Name} must have exactly 1 parameter, skipping."); - continue; - } - - var eventType = parameters[0].ParameterType; - if (!typeof(Event.Event).IsAssignableFrom(eventType)) - { - Console.WriteLine($"[FourKit] Warning: @EventHandler method {method.Name} parameter must extend Event, skipping."); - continue; - } - - if (!_handlers.TryGetValue(eventType, out var list)) - { - list = new List(); - _handlers[eventType] = list; - } - - list.Add(new RegisteredHandler(listener, method, attr.Priority, attr.IgnoreCancelled)); - _handlers[eventType] = list.OrderBy(h => h.Priority).ToList(); + Console.WriteLine($"[FourKit] Warning: @EventHandler method {method.Name} must have exactly 1 parameter, skipping."); + continue; } + + var eventType = parameters[0].ParameterType; + if (!typeof(Event.Event).IsAssignableFrom(eventType)) + { + Console.WriteLine($"[FourKit] Warning: @EventHandler method {method.Name} parameter must extend Event, skipping."); + continue; + } + + pending ??= new List<(Type, RegisteredHandler)>(); + pending.Add((eventType, new RegisteredHandler(listener, method, attr.Priority, attr.IgnoreCancelled))); + } + + if (pending == null) return; + + HashSet newlySubscribed = new(); + lock (_writeLock) + { + var newDict = new Dictionary(_handlers); + foreach (var (eventType, handler) in pending) + { + bool hadAny = newDict.TryGetValue(eventType, out var existing); + existing ??= Array.Empty(); + + // OrderBy is stable; Array.Sort is not. + var combined = existing.Append(handler).OrderBy(h => h.Priority).ToArray(); + newDict[eventType] = combined; + + if (!hadAny) newlySubscribed.Add(eventType); + } + _handlers = newDict; + } + + if (OnSubscriptionChanged != null) + { + foreach (var t in newlySubscribed) + OnSubscriptionChanged(t); } } + public void Fire(Event.Event evt) { - List? handlers; - lock (_lock) - { - if (!_handlers.TryGetValue(evt.GetType(), out handlers)) - return; - - handlers = new List(handlers); - } + var snapshot = _handlers; + if (!snapshot.TryGetValue(evt.GetType(), out var handlers)) + return; var cancellable = evt as Cancellable; - foreach (var handler in handlers) + for (int i = 0; i < handlers.Length; i++) { + ref readonly var handler = ref handlers[i]; if (handler.IgnoreCancelled && cancellable != null && cancellable.isCancelled()) continue; @@ -89,4 +109,6 @@ internal sealed class EventDispatcher } } } + + internal bool IsSubscribed(Type eventType) => _handlers.ContainsKey(eventType); } diff --git a/Minecraft.Server.FourKit/FourKit.cs b/Minecraft.Server.FourKit/FourKit.cs index 7da6bcae..062e886b 100644 --- a/Minecraft.Server.FourKit/FourKit.cs +++ b/Minecraft.Server.FourKit/FourKit.cs @@ -11,11 +11,68 @@ using Minecraft.Server.FourKit.Plugin; /// public static class FourKit { - private static readonly EventDispatcher _dispatcher = new(); + private static readonly EventDispatcher _dispatcher; private static readonly Dictionary _players = new(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary _playersByEntityId = new(); private static readonly object _playerLock = new(); + // Must match HandlerKind in FourKitNatives.h. + private enum HandlerKind + { + ChunkLoad = 0, + ChunkUnload = 1, + PlayerMove = 2, + } + + private static uint _handlerMask; + private static readonly object _handlerMaskLock = new(); + + static FourKit() + { + _dispatcher = new EventDispatcher(); + _dispatcher.OnSubscriptionChanged = OnEventSubscribed; + } + + private static HandlerKind? MapEventTypeToHandlerKind(Type eventType) + { + if (eventType == typeof(Event.World.ChunkLoadEvent)) return HandlerKind.ChunkLoad; + if (eventType == typeof(Event.World.ChunkUnloadEvent)) return HandlerKind.ChunkUnload; + if (eventType == typeof(Event.Player.PlayerMoveEvent)) return HandlerKind.PlayerMove; + return null; + } + + private static void OnEventSubscribed(Type eventType) + { + var kind = MapEventTypeToHandlerKind(eventType); + if (kind == null) return; + + lock (_handlerMaskLock) + { + uint newMask = _handlerMask | (1u << (int)kind.Value); + if (newMask == _handlerMask) return; + _handlerMask = newMask; + NativeBridge.SetHandlerMask?.Invoke(_handlerMask); + } + } + + internal static void ResyncHandlerMask() + { + lock (_handlerMaskLock) + { + NativeBridge.SetHandlerMask?.Invoke(_handlerMask); + } + } + + /// + /// Gets the current server tick count. Increments once per server tick + /// (~20 per second under nominal load). Useful for measuring TPS by + /// sampling the delta against wall clock time. + /// + public static int getServerTick() + { + return NativeBridge.GetServerTickCount?.Invoke() ?? 0; + } + internal const int MAX_CHAT_LENGTH = 123; private static readonly Dictionary _worldsByDimId = new(); diff --git a/Minecraft.Server.FourKit/FourKitHost.Callbacks.cs b/Minecraft.Server.FourKit/FourKitHost.Callbacks.cs index 575c2e00..d035813a 100644 --- a/Minecraft.Server.FourKit/FourKitHost.Callbacks.cs +++ b/Minecraft.Server.FourKit/FourKitHost.Callbacks.cs @@ -160,4 +160,32 @@ public static partial class FourKitHost ServerLog.Error("fourkit", $"SetWorldEntityCallbacks error: {ex}"); } } + + [UnmanagedCallersOnly] + public static void SetSubscriptionCallbacks(IntPtr setHandlerMask) + { + try + { + NativeBridge.SetSubscriptionCallbacks(setHandlerMask); + // Flush the mask accumulated during plugin onEnable. + FourKit.ResyncHandlerMask(); + } + catch (Exception ex) + { + ServerLog.Error("fourkit", $"SetSubscriptionCallbacks error: {ex}"); + } + } + + [UnmanagedCallersOnly] + public static void SetServerCallbacks(IntPtr getServerTickCount) + { + try + { + NativeBridge.SetServerCallbacks(getServerTickCount); + } + catch (Exception ex) + { + ServerLog.Error("fourkit", $"SetServerCallbacks error: {ex}"); + } + } } diff --git a/Minecraft.Server.FourKit/Minecraft.Server.FourKit.csproj b/Minecraft.Server.FourKit/Minecraft.Server.FourKit.csproj index ac1fff0a..3c12cecd 100644 --- a/Minecraft.Server.FourKit/Minecraft.Server.FourKit.csproj +++ b/Minecraft.Server.FourKit/Minecraft.Server.FourKit.csproj @@ -7,5 +7,7 @@ Minecraft.Server.FourKit true bin + true + true diff --git a/Minecraft.Server.FourKit/NativeBridge.cs b/Minecraft.Server.FourKit/NativeBridge.cs index b936e390..ec53e14d 100644 --- a/Minecraft.Server.FourKit/NativeBridge.cs +++ b/Minecraft.Server.FourKit/NativeBridge.cs @@ -236,6 +236,15 @@ internal static class NativeBridge [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate void NativeSetBiomeIdDelegate(int dimId, int x, int z, int biomeId); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal delegate void NativeSetHandlerMaskDelegate(uint mask); + internal static NativeSetHandlerMaskDelegate? SetHandlerMask; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal delegate int NativeGetServerTickCountDelegate(); + internal static NativeGetServerTickCountDelegate? GetServerTickCount; + internal static NativeDamageDelegate? DamagePlayer; internal static NativeSetHealthDelegate? SetPlayerHealth; internal static NativeTeleportDelegate? TeleportPlayer; @@ -439,4 +448,14 @@ internal static class NativeBridge GetBiomeId = Marshal.GetDelegateForFunctionPointer(getBiomeId); SetBiomeId = Marshal.GetDelegateForFunctionPointer(setBiomeId); } + + internal static void SetSubscriptionCallbacks(IntPtr setHandlerMask) + { + SetHandlerMask = Marshal.GetDelegateForFunctionPointer(setHandlerMask); + } + + internal static void SetServerCallbacks(IntPtr getServerTickCount) + { + GetServerTickCount = Marshal.GetDelegateForFunctionPointer(getServerTickCount); + } } diff --git a/Minecraft.Server/FourKitBridge.cpp b/Minecraft.Server/FourKitBridge.cpp index 24dea44f..dfe90c4b 100644 --- a/Minecraft.Server/FourKitBridge.cpp +++ b/Minecraft.Server/FourKitBridge.cpp @@ -106,6 +106,8 @@ typedef int(__stdcall *fn_fire_block_from_to)(int dimId, int fromX, int fromY, i typedef void(__stdcall *fn_set_chunk_callbacks)(void *isChunkLoaded, void *loadChunk, void *unloadChunk, void *getLoadedChunks, void *isChunkInUse, void *getChunkSnapshot, void *unloadChunkRequest, void *regenerateChunk, void *refreshChunk); typedef void(__stdcall *fn_set_block_info_callbacks)(void *getSkyLight, void *getBlockLight, void *getBiomeId, void *setBiomeId); typedef void(__stdcall *fn_set_world_entity_callbacks)(void *getWorldEntities, void *getChunkEntities); +typedef void(__stdcall *fn_set_subscription_callbacks)(void *setHandlerMask); +typedef void(__stdcall *fn_set_server_callbacks)(void *getServerTickCount); typedef void(__stdcall *fn_fire_chunk_load)(int dimId, int chunkX, int chunkZ, int isNewChunk); typedef int(__stdcall *fn_fire_chunk_unload)(int dimId, int chunkX, int chunkZ); @@ -168,6 +170,8 @@ static fn_fire_block_from_to s_managedFireBlockFromTo = nullptr; static fn_set_chunk_callbacks s_managedSetChunkCallbacks = nullptr; static fn_set_block_info_callbacks s_managedSetBlockInfoCallbacks = nullptr; static fn_set_world_entity_callbacks s_managedSetWorldEntityCallbacks = nullptr; +static fn_set_subscription_callbacks s_managedSetSubscriptionCallbacks = nullptr; +static fn_set_server_callbacks s_managedSetServerCallbacks = nullptr; static fn_fire_chunk_load s_managedFireChunkLoad = nullptr; static fn_fire_chunk_unload s_managedFireChunkUnload = nullptr; @@ -255,6 +259,8 @@ void Initialize() {L"SetChunkCallbacks", (void **)&s_managedSetChunkCallbacks}, {L"SetBlockInfoCallbacks", (void **)&s_managedSetBlockInfoCallbacks}, {L"SetWorldEntityCallbacks", (void **)&s_managedSetWorldEntityCallbacks}, + {L"SetSubscriptionCallbacks", (void **)&s_managedSetSubscriptionCallbacks}, + {L"SetServerCallbacks", (void **)&s_managedSetServerCallbacks}, {L"FireChunkLoad", (void **)&s_managedFireChunkLoad}, {L"FireChunkUnload", (void **)&s_managedFireChunkUnload}, }; @@ -376,6 +382,12 @@ void Initialize() (void *)&NativeGetWorldEntities, (void *)&NativeGetChunkEntities); + s_managedSetSubscriptionCallbacks( + (void *)&NativeSetHandlerMask); + + s_managedSetServerCallbacks( + (void *)&NativeGetServerTickCount); + LogInfo("fourkit", "FourKit initialized successfully."); } @@ -521,8 +533,12 @@ bool FirePlayerMove(int entityId, double toX, double toY, double toZ, double *outToX, double *outToY, double *outToZ) { - if (!s_initialized || !s_managedFireMove) + // Caller reads outTo* unconditionally; init on every early-return. + if (!s_initialized || !s_managedFireMove || !HasHandlers(kHandlerKind_PlayerMove)) { + *outToX = toX; + *outToY = toY; + *outToZ = toZ; return false; } @@ -1059,6 +1075,8 @@ void FireChunkLoad(int dimId, int chunkX, int chunkZ, bool isNewChunk) { if (!s_initialized || !s_managedFireChunkLoad) return; + if (!HasHandlers(kHandlerKind_ChunkLoad)) + return; s_managedFireChunkLoad(dimId, chunkX, chunkZ, isNewChunk ? 1 : 0); } @@ -1066,6 +1084,8 @@ bool FireChunkUnload(int dimId, int chunkX, int chunkZ) { if (!s_initialized || !s_managedFireChunkUnload) return false; + if (!HasHandlers(kHandlerKind_ChunkUnload)) + return false; return s_managedFireChunkUnload(dimId, chunkX, chunkZ) != 0; } } // namespace FourKitBridge diff --git a/Minecraft.Server/FourKitNatives.cpp b/Minecraft.Server/FourKitNatives.cpp index 8f2d713c..6b3aa189 100644 --- a/Minecraft.Server/FourKitNatives.cpp +++ b/Minecraft.Server/FourKitNatives.cpp @@ -3,6 +3,7 @@ #include "Common/StringUtils.h" #include "stdafx.h" +#include #include #include @@ -51,6 +52,8 @@ namespace { +std::atomic g_handlerMask{0}; + static shared_ptr FindPlayer(int entityId) { PlayerList *list = MinecraftServer::getPlayerList(); @@ -109,6 +112,24 @@ class VirtualContainer : public SimpleContainer namespace FourKitBridge { + +void __cdecl NativeSetHandlerMask(uint32_t mask) +{ + g_handlerMask.store(mask, std::memory_order_release); +} + +bool HasHandlers(int kind) +{ + if (kind < 0 || kind >= 32) return false; + return (g_handlerMask.load(std::memory_order_acquire) & (1u << kind)) != 0; +} + +int __cdecl NativeGetServerTickCount() +{ + MinecraftServer *srv = MinecraftServer::getInstance(); + return srv ? srv->tickCount : 0; +} + void __cdecl NativeDamagePlayer(int entityId, float amount) { auto player = FindPlayer(entityId); diff --git a/Minecraft.Server/FourKitNatives.h b/Minecraft.Server/FourKitNatives.h index ae241e86..0b77a396 100644 --- a/Minecraft.Server/FourKitNatives.h +++ b/Minecraft.Server/FourKitNatives.h @@ -1,8 +1,21 @@ #pragma once +#include namespace FourKitBridge { + // Must match HandlerKind in FourKit.cs. + enum HandlerKind : int { + kHandlerKind_ChunkLoad = 0, + kHandlerKind_ChunkUnload = 1, + kHandlerKind_PlayerMove = 2, + }; + + void __cdecl NativeSetHandlerMask(uint32_t mask); + bool HasHandlers(int kind); + + int __cdecl NativeGetServerTickCount(); + // core void __cdecl NativeDamagePlayer(int entityId, float amount); void __cdecl NativeSetPlayerHealth(int entityId, float health); diff --git a/samples/FourKitTestPlugin/FourKitTestPlugin.cs b/samples/FourKitTestPlugin/FourKitTestPlugin.cs index fbae6a4f..18432020 100644 --- a/samples/FourKitTestPlugin/FourKitTestPlugin.cs +++ b/samples/FourKitTestPlugin/FourKitTestPlugin.cs @@ -36,16 +36,21 @@ public class FourKitTestPlugin : ServerPlugin _logPath = ResolveLogPath(serverDirectory, dataDirectory); Log("FourKitTestPlugin enabled."); Log($"Plugin log file: {_logPath}"); - FourKit.addListener(new ChunkEventLogger()); + // ChunkEventLogger is intentionally NOT registered here. Subscribing + // to chunk events flips the C++ HasHandlers mask bit, which disables + // the no-listener fast-path. Use /fktest hookchunks to register it + // when you want to measure dispatch overhead specifically. + TpsProbe.Start(); var cmd = FourKit.getCommand("fktest"); cmd.setDescription("FourKit API smoke tests."); - cmd.setUsage("/fktest "); + cmd.setUsage("/fktest "); cmd.setExecutor(new TestExecutor()); } public override void onDisable() { + TpsProbe.Stop(); Log("FourKitTestPlugin disabled."); } @@ -54,6 +59,9 @@ public class FourKitTestPlugin : ServerPlugin internal static void IncChunkLoad() => Interlocked.Increment(ref _chunkLoadCount); internal static void IncChunkUnload() => Interlocked.Increment(ref _chunkUnloadCount); + internal static volatile bool WatchChunks = false; + internal static volatile bool ChunkListenerHooked = false; + /// /// Writes a line both to the live server console and to a persistent log /// file so test results are recoverable after the server window closes. @@ -105,12 +113,93 @@ public class FourKitTestPlugin : ServerPlugin } } +internal static class TpsProbe +{ + private static readonly object _lock = new(); + private static readonly LinkedList<(double elapsed, int tick)> _samples = new(); + private static System.Threading.Timer? _timer; + private static System.Diagnostics.Stopwatch? _sw; + + public static void Start() + { + _sw = System.Diagnostics.Stopwatch.StartNew(); + // Seed an initial sample so a /fktest tps in the first second is meaningful. + Sample(); + _timer = new System.Threading.Timer(_ => Sample(), null, 1000, 1000); + } + + public static void Stop() + { + _timer?.Dispose(); + _timer = null; + _sw?.Stop(); + _sw = null; + lock (_lock) _samples.Clear(); + } + + private static void Sample() + { + var sw = _sw; + if (sw == null) return; + try + { + int tick = FourKit.getServerTick(); + double elapsed = sw.Elapsed.TotalSeconds; + lock (_lock) + { + _samples.AddLast((elapsed, tick)); + // Keep a 65s window so the 60s average has full data. + while (_samples.Count > 66) _samples.RemoveFirst(); + } + } + catch (Exception ex) + { + Console.WriteLine($"[fkplugin] TpsProbe sample error: {ex.Message}"); + } + } + + public static (int samples, double tps1, double tps5, double tps30, double tps60) Read() + { + (double elapsed, int tick)[] arr; + lock (_lock) + { + if (_samples.Count < 2) return (_samples.Count, 0, 0, 0, 0); + arr = _samples.ToArray(); + } + + var last = arr[^1]; + + double Window(double seconds) + { + for (int j = arr.Length - 2; j >= 0; j--) + { + if (last.elapsed - arr[j].elapsed >= seconds) + { + var first = arr[j]; + double dt = last.elapsed - first.elapsed; + return dt > 0 ? (last.tick - first.tick) / dt : 0; + } + } + // Not enough history yet: report what we have. + var oldest = arr[0]; + double dt0 = last.elapsed - oldest.elapsed; + return dt0 > 0 ? (last.tick - oldest.tick) / dt0 : 0; + } + + return (arr.Length, Window(1), Window(5), Window(30), Window(60)); + } +} + +// Chunk events fire at high frequency under load (16 chunks/player/tick on +// the dedicated server). Doing disk I/O per event tanks server TPS. Default +// to counter-only; /fktest watchchunks toggles verbose disk logging on. internal sealed class ChunkEventLogger : Listener { [EventHandler(Priority = EventPriority.Monitor)] public void onChunkLoad(ChunkLoadEvent e) { FourKitTestPlugin.IncChunkLoad(); + if (!FourKitTestPlugin.WatchChunks) return; var chunk = e.getChunk(); FourKitTestPlugin.Log($"ChunkLoadEvent dim={chunk.getWorld().getDimensionId()} ({chunk.getX()},{chunk.getZ()}) new={e.isNewChunk()}"); } @@ -119,6 +208,7 @@ internal sealed class ChunkEventLogger : Listener public void onChunkUnload(ChunkUnloadEvent e) { FourKitTestPlugin.IncChunkUnload(); + if (!FourKitTestPlugin.WatchChunks) return; var chunk = e.getChunk(); FourKitTestPlugin.Log($"ChunkUnloadEvent dim={chunk.getWorld().getDimensionId()} ({chunk.getX()},{chunk.getZ()})"); } @@ -136,7 +226,7 @@ internal sealed class TestExecutor : CommandExecutor { if (args.Length == 0) { - Reply(sender,"Usage: /fktest "); + Reply(sender,"Usage: /fktest "); return true; } @@ -154,6 +244,22 @@ internal sealed class TestExecutor : CommandExecutor case "disenchant": return TestDisenchant(sender); case "setblock": return TestSetBlock(sender); case "chatcolor": return TestChatColor(sender); + case "tps": return TestTps(sender); + case "scatter": return TestScatter(sender, args); + case "hookchunks": + if (FourKitTestPlugin.ChunkListenerHooked) + { + Reply(sender, "Chunk listener already hooked. EventDispatcher has no unregister, so this is one-way for the session."); + return true; + } + FourKit.addListener(new ChunkEventLogger()); + FourKitTestPlugin.ChunkListenerHooked = true; + Reply(sender, "Chunk listener registered. HasHandlers fast-path now off; chunk events will dispatch."); + return true; + case "watchchunks": + FourKitTestPlugin.WatchChunks = !FourKitTestPlugin.WatchChunks; + Reply(sender, $"Verbose chunk-event disk logging {(FourKitTestPlugin.WatchChunks ? "ON" : "OFF")} (only effective once /fktest hookchunks is run)"); + return true; case "events": Reply(sender,$"Chunk loads observed: {FourKitTestPlugin.ChunkLoadCount}"); Reply(sender,$"Chunk unloads observed: {FourKitTestPlugin.ChunkUnloadCount}"); @@ -184,6 +290,10 @@ internal sealed class TestExecutor : CommandExecutor Reply(sender,"/fktest events - Show observed chunk-event counters"); Reply(sender,"/fktest setblock - Place wool 3 above head via setTypeIdAndData, read back"); Reply(sender,"/fktest chatcolor - Verify ChatColor parsing/strip/translate"); + Reply(sender,"/fktest tps - Show server TPS over 1s/5s/30s/60s windows"); + Reply(sender,"/fktest scatter [N] - Teleport every online player to a random point within +-N blocks (default 1500)"); + Reply(sender,"/fktest hookchunks - Register the chunk listener (turns OFF the no-listener fast-path)"); + Reply(sender,"/fktest watchchunks - Toggle per-event disk logging (off by default; expensive)"); } private static Player? RequirePlayer(CommandSender sender) @@ -193,6 +303,54 @@ internal sealed class TestExecutor : CommandExecutor return null; } + private static bool TestScatter(CommandSender sender, string[] args) + { + int range = 1500; + if (args.Length > 1 && int.TryParse(args[1], out int parsed) && parsed > 0) + range = parsed; + + var world = FourKit.getWorld(0); + if (world == null) + { + Reply(sender, "Could not resolve overworld."); + return true; + } + + var rng = new Random(); + int count = 0; + foreach (var p in FourKit.getOnlinePlayers()) + { + int x = rng.Next(-range, range + 1); + int z = rng.Next(-range, range + 1); + int y = world.getHighestBlockYAt(x, z) + 1; + try + { + p.teleport(new Location(world, x, y, z)); + count++; + } + catch (Exception ex) + { + FourKitTestPlugin.Log($"scatter: failed to teleport {p.getName()}: {ex.Message}"); + } + } + Reply(sender, $"Scattered {count} player(s) within ±{range} blocks."); + return true; + } + + private static bool TestTps(CommandSender sender) + { + var (samples, t1, t5, t30, t60) = TpsProbe.Read(); + if (samples < 2) + { + Reply(sender, $"TPS probe warming up ({samples}/2 samples). Try again in a few seconds."); + return true; + } + int tick = FourKit.getServerTick(); + Reply(sender, $"TPS 1s={t1:F2} 5s={t5:F2} 30s={t30:F2} 60s={t60:F2}"); + Reply(sender, $"server tick={tick} samples={samples}"); + return true; + } + private static bool TestWorld(CommandSender sender) { var player = RequirePlayer(sender); diff --git a/tools/stress-test/stress_test.py b/tools/stress-test/stress_test.py index e746f380..414337d5 100644 --- a/tools/stress-test/stress_test.py +++ b/tools/stress-test/stress_test.py @@ -20,6 +20,7 @@ Options: import argparse import logging +import math import os import random import secrets @@ -68,7 +69,9 @@ CIPHER_ON_PATTERN = ( b"\x00\x00" ) +CIPHER_KEY_CHANNEL = "MC|CKey" CIPHER_ACK_CHANNEL = "MC|CAck" +CIPHER_ON_CHANNEL = "MC|COn" IDENTITY_TOKEN_ISSUE = "MC|CTIssue" IDENTITY_TOKEN_CHALLENGE = "MC|CTChallenge" IDENTITY_TOKEN_RESPONSE = "MC|CTResponse" @@ -130,14 +133,17 @@ class Stats: # Movement packet builder # --------------------------------------------------------------------------- -MOVE_PLAYER = 0x0D # MovePlayerPacket ID +MOVE_PLAYER = 0x0D # MovePlayerPacket::PosRot — what we send AND what server sends for teleports. def build_move_player(x: float, y: float, z: float, yaw: float, pitch: float, on_ground: bool) -> bytes: + # Wire order matches MovePlayerPacket::PosRot::write: x, y (feet), + # yView (eye), z, yaw, pitch, flags. Server kicks for IllegalStance + # if (yView - y) is outside [0.1, 1.65], so feet must come first. dos = DataOutputStream() dos.write_double(x) - dos.write_double(y + 1.62) # stance dos.write_double(y) + dos.write_double(y + 1.62) dos.write_double(z) dos.write_float(yaw) dos.write_float(pitch) @@ -174,6 +180,13 @@ class StressBot: self._identity_token = b"" self._entity_id = 0 self._running = True + # Server-tracked position. Initialized when server sends its first + # MovePlayer::PosRot teleport after login, and updated whenever the + # server teleports us (eg. plugin scatter, anti-cheat correction). + self._pos_x = 0.0 + self._pos_y = 64.0 + self._pos_z = 0.0 + self._pos_initialized = False def log(self, msg: str) -> None: if not self.quiet: @@ -250,8 +263,6 @@ class StressBot: hold_end = time.time() + hold_time last_keepalive = time.time() keepalive_counter = 0 - move_x, move_z = random.uniform(-50, 50), random.uniform(-50, 50) - move_y = 65.0 while time.time() < hold_end and self._running: # Drain incoming data @@ -276,13 +287,22 @@ class StressBot: self.stats.keepalives_sent += 1 last_keepalive = now - # Movement packets every 50ms - if self.send_moves: - move_x += random.uniform(-0.5, 0.5) - move_z += random.uniform(-0.5, 0.5) + # Movement packets every 50ms. We can't do real travel because + # the server's anti-cheat compares our claimed position against + # what its own physics computes, and we don't simulate collision + # or gravity. Instead we drift ±0.3 blocks from whatever + # position the server most recently teleported us to. To spread + # bots out, use the test plugin's /fktest scatter from in-game. + if self.send_moves and self._pos_initialized: + new_x = self._pos_x + random.uniform(-0.3, 0.3) + new_z = self._pos_z + random.uniform(-0.3, 0.3) yaw = random.uniform(0, 360) self._send_packet(MOVE_PLAYER, - build_move_player(move_x, move_y, move_z, yaw, 0.0, True)) + build_move_player(new_x, self._pos_y, new_z, yaw, 0.0, True)) + # Optimistically update; server will correct us via PosRot + # if it disagreed (eg. we drifted into a block). + self._pos_x = new_x + self._pos_z = new_z with self.stats.lock: self.stats.moves_sent += 1 time.sleep(0.05) @@ -294,11 +314,23 @@ class StressBot: return True def _do_cipher_scan(self) -> None: - """Scan for cipher handshake for up to 3 seconds.""" + """Wait for the cipher handshake to finish or up to ~4s. + + Returns early once both keys are exchanged. The upper bound has to + cover the worst case where a stack of plaintext setup packets + (level info, scoreboard, initial chunks) sits in front of MC|CKey + in the recv buffer. The server's own cipher-handshake grace is + 100 ticks (~5s). + """ scan_start = time.time() scan_buf = bytearray() - while time.time() - scan_start < 0.5 and self._running: + while time.time() - scan_start < 4.0 and self._running: + # _handle_custom_payload may have already activated cipher via + # the drain path inside _read_until_packet. + if self._cipher_key and self._recv_cipher: + return + try: chunk = self._sock.recv(65536) if not chunk: @@ -393,6 +425,14 @@ class StressBot: pass return True + # Handle cipher handshake during the login wait. With + # require-secure-client, the server holds the Login response + # behind the security gate until cipher activates, so the + # gate-bypass MC|CKey/MC|COn frames arrive before LOGIN. + # Dropping them here would deadlock both sides. + elif packet_id == CUSTOM_PAYLOAD: + self._handle_custom_payload(data) + elif packet_id == DISCONNECT: try: dis = DataInputStream(data) @@ -428,16 +468,51 @@ class StressBot: # Handle identity tokens if packet_id == CUSTOM_PAYLOAD: self._handle_custom_payload(data) + elif packet_id == MOVE_PLAYER: + self._handle_server_move(data) + + def _handle_server_move(self, data: bytes) -> None: + """Track server's view of our position. PosRot format: + double x, double y, double yView, double z, float yRot, float xRot, byte flags.""" + try: + dis = DataInputStream(data) + x = dis.read_double() + y = dis.read_double() + _yView = dis.read_double() + z = dis.read_double() + self._pos_x = x + self._pos_y = y + self._pos_z = z + self._pos_initialized = True + except Exception: + pass def _handle_custom_payload(self, data: bytes) -> None: - """Handle identity token packets.""" + """Handle cipher handshake and identity token channels.""" try: dis = DataInputStream(data) channel = dis.read_utf() length = dis.read_short() payload = dis.read_raw(length) if length > 0 else b"" - if channel == IDENTITY_TOKEN_ISSUE and len(payload) == 32: + # Cipher channels arrive in plaintext before encryption is active. + # Handle them here so the bot survives bursts where the whole + # handshake frame lands in a single recv(), bypassing the leftover + # byte-pattern scan. + if channel == CIPHER_KEY_CHANNEL and len(payload) == 32 and not self._cipher_key: + self._cipher_key = payload[:16] + self._cipher_iv = payload[16:32] + self.log(f"[{self.name}] got cipher key") + self._send_packet(CUSTOM_PAYLOAD, + build_custom_payload(CIPHER_ACK_CHANNEL)) + iv_send = bytearray(self._cipher_iv) + iv_send[0] ^= 0x80 + self._send_cipher = CipherState(self._cipher_key, bytes(iv_send)) + elif channel == CIPHER_ON_CHANNEL: + if self._cipher_key and not self._recv_cipher: + self._recv_cipher = CipherState(self._cipher_key, self._cipher_iv) + self.log(f"[{self.name}] cipher active") + elif channel == IDENTITY_TOKEN_ISSUE and len(payload) == 32: self._identity_token = payload self.log(f"[{self.name}] got identity token") elif channel == IDENTITY_TOKEN_CHALLENGE: diff --git a/tools/stress-test/test_fourkit_chunk.bat b/tools/stress-test/test_fourkit_chunk.bat new file mode 100644 index 00000000..95712630 --- /dev/null +++ b/tools/stress-test/test_fourkit_chunk.bat @@ -0,0 +1,14 @@ +@echo off +REM FourKit chunk + move event stress: 50 concurrent moving bots held for 1-2min +REM each, exercising FireChunkLoad / FireChunkUnload / FirePlayerMove. Validates +REM the HasHandlers fast-path and Server GC at the 50-player target. +REM +REM Set require-secure-client=false in server.properties before running. The +REM 100-tick cipher handshake grace cannot keep up with 50 simultaneous bot +REM joins, which is unrelated to what this test is measuring. +set /p HOST="Server IP [127.0.0.1]: " || set HOST=127.0.0.1 +set /p PORT="Server Port [25565]: " || set PORT=25565 +if "%HOST%"=="" set HOST=127.0.0.1 +if "%PORT%"=="" set PORT=25565 +python "%~dp0stress_test.py" %HOST% %PORT% --bots 50 --burst 10 --move --hold 60 120 --ramp 0.5 --duration 600 --cycles 0 --quiet +pause