From be17d4028faa9a5f52435eff00d16a2c05f831e2 Mon Sep 17 00:00:00 2001 From: itsRevela Date: Sun, 5 Apr 2026 15:49:52 -0500 Subject: [PATCH] feat: add performance monitor tool DLL injector + Python GUI for real-time server metrics. Hooks into tick loop, chunk generation, and player chunk map to collect timing data over TCP, displayed in a tkinter dashboard. Includes bot spawner for load testing. --- tools/performance-monitor/bots.py | 425 +++++++++++++ tools/performance-monitor/build-dll.bat | 25 + tools/performance-monitor/connection.py | 157 +++++ tools/performance-monitor/dll/CMakeLists.txt | 33 ++ tools/performance-monitor/dll/src/dllmain.cpp | 172 ++++++ tools/performance-monitor/dll/src/hooks.cpp | 337 +++++++++++ .../dll/src/json_writer.cpp | 132 +++++ tools/performance-monitor/dll/src/metrics.cpp | 384 ++++++++++++ .../dll/src/perf_monitor.h | 143 +++++ tools/performance-monitor/dll/src/symbols.cpp | 120 ++++ .../dll/src/tcp_server.cpp | 297 ++++++++++ tools/performance-monitor/gui.py | 557 ++++++++++++++++++ tools/performance-monitor/inject.bat | 4 + tools/performance-monitor/inject.py | 294 +++++++++ tools/performance-monitor/main.py | 70 +++ tools/performance-monitor/models.py | 122 ++++ tools/performance-monitor/requirements.txt | 1 + tools/performance-monitor/start.bat | 3 + tools/performance-monitor/widgets.py | 494 ++++++++++++++++ 19 files changed, 3770 insertions(+) create mode 100644 tools/performance-monitor/bots.py create mode 100644 tools/performance-monitor/build-dll.bat create mode 100644 tools/performance-monitor/connection.py create mode 100644 tools/performance-monitor/dll/CMakeLists.txt create mode 100644 tools/performance-monitor/dll/src/dllmain.cpp create mode 100644 tools/performance-monitor/dll/src/hooks.cpp create mode 100644 tools/performance-monitor/dll/src/json_writer.cpp create mode 100644 tools/performance-monitor/dll/src/metrics.cpp create mode 100644 tools/performance-monitor/dll/src/perf_monitor.h create mode 100644 tools/performance-monitor/dll/src/symbols.cpp create mode 100644 tools/performance-monitor/dll/src/tcp_server.cpp create mode 100644 tools/performance-monitor/gui.py create mode 100644 tools/performance-monitor/inject.bat create mode 100644 tools/performance-monitor/inject.py create mode 100644 tools/performance-monitor/main.py create mode 100644 tools/performance-monitor/models.py create mode 100644 tools/performance-monitor/requirements.txt create mode 100644 tools/performance-monitor/start.bat create mode 100644 tools/performance-monitor/widgets.py diff --git a/tools/performance-monitor/bots.py b/tools/performance-monitor/bots.py new file mode 100644 index 00000000..34a6d86a --- /dev/null +++ b/tools/performance-monitor/bots.py @@ -0,0 +1,425 @@ +""" +Bot player manager for server load testing. + +Each bot connects to the game server as a real client using the same +connection flow as the server-monitor tool (PreLogin -> Login -> cipher +handshake -> identity tokens -> keepalive). +""" + +import logging +import os +import secrets +import socket +import struct +import sys +import threading +import time +from dataclasses import dataclass + +from PySide6.QtCore import QObject, Signal + +# Import protocol code from the sibling server-monitor tool. +_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SM_PATH = os.path.join(_PARENT, "server-monitor") +if _SM_PATH not in sys.path: + sys.path.append(_SM_PATH) + +from protocol import frame_packet, DataInputStream +from packets import ( + PRE_LOGIN, LOGIN, KEEP_ALIVE, CUSTOM_PAYLOAD, DISCONNECT, + build_prelogin, build_login, build_keep_alive, + build_custom_payload, parse_prelogin_response, + parse_login_response_stream, DISCONNECT_REASONS, +) + +logger = logging.getLogger("perfmon") + +# Reuse the same constants/patterns as server-monitor/connection.py +SMALLID_REJECT = 0xFF + +CIPHER_KEY_PREFIX = ( + b"\xFA\x00\x07" + b"\x00\x4D\x00\x43\x00\x7C\x00\x43\x00\x4B\x00\x65\x00\x79" + b"\x00\x20" +) +CIPHER_KEY_TOTAL = len(CIPHER_KEY_PREFIX) + 32 + +CIPHER_ON_PATTERN = ( + b"\xFA\x00\x06" + b"\x00\x4D\x00\x43\x00\x7C\x00\x43\x00\x4F\x00\x6E" + b"\x00\x00" +) + +CIPHER_ACK_CHANNEL = "MC|CAck" +IDENTITY_TOKEN_ISSUE = "MC|CTIssue" +IDENTITY_TOKEN_CHALLENGE = "MC|CTChallenge" +IDENTITY_TOKEN_RESPONSE = "MC|CTResponse" + +PHASE_HANDSHAKE = 0 +PHASE_CIPHER_SCAN = 1 +PHASE_MONITORING = 2 + + +def _build_channel_pattern(channel: str) -> bytes: + result = b"\xFA" + result += struct.pack(">h", len(channel)) + for ch in channel: + result += struct.pack(">H", ord(ch)) + return result + + +class CipherState: + def __init__(self, key: bytes, iv: bytes): + from Crypto.Cipher import AES + from Crypto.Util import Counter + ctr = Counter.new(128, initial_value=int.from_bytes(iv, "big")) + self._cipher = AES.new(key, AES.MODE_CTR, counter=ctr) + + def process(self, data: bytes) -> bytes: + return self._cipher.encrypt(data) + + +@dataclass +class BotState: + name: str + thread: threading.Thread | None = None + sock: socket.socket | None = None + running: bool = False + connected: bool = False + + +class BotConnection: + """Single bot connection -- mirrors ServerConnection from server-monitor.""" + + def __init__(self, bot: BotState, host: str, port: int, xuid: int, + log_fn, on_connected_fn): + self._bot = bot + self._host = host + self._port = port + self._xuid = xuid + self._log = log_fn + self._on_connected = on_connected_fn + self._sock: socket.socket | None = None + self._recv_buf = bytearray() + self._scan_buf = bytearray() + self._phase = PHASE_HANDSHAKE + self._got_prelogin = False + self._got_login = False + self._recv_cipher: CipherState | None = None + self._send_cipher: CipherState | None = None + self._cipher_key = b"" + self._cipher_iv = b"" + self._cipher_scan_start = 0.0 + self._identity_token = b"" + + def run(self): + self._log(f"Bot '{self._bot.name}' connecting to {self._host}:{self._port}...") + self._sock = socket.create_connection((self._host, self._port), timeout=10) + self._bot.sock = self._sock + self._sock.settimeout(5.0) + + # Phase 1: SmallID assignment + first_byte = self._recv_exact(1) + if first_byte[0] == SMALLID_REJECT: + reject_data = self._recv_exact(5) + reason_id = struct.unpack(">i", reject_data[1:5])[0] + reason = DISCONNECT_REASONS.get(reason_id, f"Unknown({reason_id})") + self._log(f"Bot '{self._bot.name}' rejected: {reason}") + return + + self._log(f"Bot '{self._bot.name}' assigned smallId={first_byte[0]}") + + # Send PreLogin + self._send_packet(PRE_LOGIN, build_prelogin(self._bot.name)) + self._log(f"Bot '{self._bot.name}' sent PreLogin") + + # Main recv loop + self._sock.settimeout(1.0) + last_keepalive = time.time() + keepalive_counter = 0 + + while self._bot.running: + try: + chunk = self._sock.recv(65536) + except socket.timeout: + # Cipher scan timeout + if (self._phase == PHASE_CIPHER_SCAN + and not self._cipher_key + and time.time() - self._cipher_scan_start > 3.0): + self._handle_cipher_scan(b"") + # Keepalive + now = time.time() + if now - last_keepalive >= 10.0: + keepalive_counter += 1 + self._send_packet(KEEP_ALIVE, build_keep_alive(keepalive_counter)) + last_keepalive = now + continue + except OSError: + break + + if not chunk: + self._log(f"Bot '{self._bot.name}' server closed connection") + break + + self._recv_buf.extend(chunk) + self._process_frames() + + now = time.time() + if now - last_keepalive >= 10.0: + keepalive_counter += 1 + self._send_packet(KEEP_ALIVE, build_keep_alive(keepalive_counter)) + last_keepalive = now + + self._log(f"Bot '{self._bot.name}' disconnected") + + def _process_frames(self): + while len(self._recv_buf) >= 4: + payload_len = struct.unpack(">I", self._recv_buf[:4])[0] + total = 4 + payload_len + if len(self._recv_buf) < total: + break + + raw_payload = bytes(self._recv_buf[4:total]) + del self._recv_buf[:total] + + if self._recv_cipher: + raw_payload = self._recv_cipher.process(raw_payload) + + if not raw_payload: + continue + + if self._phase == PHASE_HANDSHAKE: + self._handle_handshake(raw_payload) + elif self._phase == PHASE_CIPHER_SCAN: + self._handle_cipher_scan(raw_payload) + elif self._phase == PHASE_MONITORING: + self._handle_monitoring(raw_payload) + + def _handle_handshake(self, payload: bytes): + if not payload: + return + packet_id = payload[0] + data = payload[1:] + + if packet_id == PRE_LOGIN and not self._got_prelogin: + try: + parsed = parse_prelogin_response(data) + self._got_prelogin = True + self._log(f"Bot '{self._bot.name}' PreLogin OK: {parsed['player_count']} players online") + self._send_packet(LOGIN, build_login(self._bot.name, self._xuid)) + self._log(f"Bot '{self._bot.name}' sent Login") + except Exception as e: + self._log(f"Bot '{self._bot.name}' PreLogin parse error: {e}") + + elif packet_id == LOGIN and not self._got_login: + try: + dis = DataInputStream(data) + parsed = parse_login_response_stream(dis) + self._got_login = True + self._log(f"Bot '{self._bot.name}' Login OK: entityId={parsed['entity_id']}") + self._phase = PHASE_CIPHER_SCAN + self._cipher_scan_start = time.time() + # Feed remaining bytes from this frame into cipher scan + remaining = data[len(data) - dis.remaining:] + if remaining: + self._handle_cipher_scan(remaining) + except Exception as e: + self._log(f"Bot '{self._bot.name}' Login parse error: {e}") + + elif packet_id == DISCONNECT: + try: + dis = DataInputStream(data) + reason_id = dis.read_int() + reason = DISCONNECT_REASONS.get(reason_id, f"Unknown({reason_id})") + self._log(f"Bot '{self._bot.name}' kicked: {reason}") + except Exception: + self._log(f"Bot '{self._bot.name}' kicked (unknown reason)") + self._bot.running = False + + def _handle_cipher_scan(self, payload: bytes): + self._scan_buf.extend(payload) + + # Timeout: no cipher key after 3s = server has cipher disabled + if (not self._cipher_key + and time.time() - self._cipher_scan_start > 3.0): + self._log(f"Bot '{self._bot.name}' no cipher, proceeding unencrypted") + self._phase = PHASE_MONITORING + self._bot.connected = True + self._on_connected() + buf = bytes(self._scan_buf) + self._scan_buf.clear() + if buf: + self._handle_monitoring(buf) + return + + # Look for MC|CKey + if not self._cipher_key: + idx = self._scan_buf.find(CIPHER_KEY_PREFIX) + if idx >= 0 and idx + CIPHER_KEY_TOTAL <= len(self._scan_buf): + key_start = idx + len(CIPHER_KEY_PREFIX) + key_data = bytes(self._scan_buf[key_start:key_start + 32]) + self._cipher_key = key_data[:16] + self._cipher_iv = key_data[16:32] + self._log(f"Bot '{self._bot.name}' got cipher key") + + # Send MC|CAck and activate send cipher + 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)) + self._log(f"Bot '{self._bot.name}' send cipher active") + + del self._scan_buf[:idx + CIPHER_KEY_TOTAL] + + # Look for MC|COn + if self._cipher_key and not self._recv_cipher: + idx = self._scan_buf.find(CIPHER_ON_PATTERN) + if idx >= 0: + self._recv_cipher = CipherState(self._cipher_key, self._cipher_iv) + self._log(f"Bot '{self._bot.name}' cipher handshake complete") + self._phase = PHASE_MONITORING + self._bot.connected = True + self._on_connected() + self._scan_buf.clear() + + def _handle_monitoring(self, payload: bytes): + self._scan_buf.extend(payload) + self._scan_identity_tokens() + # Trim buffer + if len(self._scan_buf) > 4096: + del self._scan_buf[:-512] + + def _scan_identity_tokens(self): + # MC|CTIssue: server sends a token for us to store + issue_prefix = _build_channel_pattern(IDENTITY_TOKEN_ISSUE) + idx = self._scan_buf.find(issue_prefix) + if idx >= 0: + data_start = idx + len(issue_prefix) + if data_start + 2 + 32 <= len(self._scan_buf): + data_len = struct.unpack(">h", self._scan_buf[data_start:data_start+2])[0] + if data_len == 32: + token = bytes(self._scan_buf[data_start+2:data_start+2+32]) + self._identity_token = token + self._log(f"Bot '{self._bot.name}' received identity token") + del self._scan_buf[:data_start + 2 + 32] + return + + # MC|CTChallenge: server wants us to present our token + challenge_prefix = _build_channel_pattern(IDENTITY_TOKEN_CHALLENGE) + idx = self._scan_buf.find(challenge_prefix) + if idx >= 0: + data_start = idx + len(challenge_prefix) + if data_start + 2 <= len(self._scan_buf): + del self._scan_buf[:data_start + 2] + if self._identity_token and len(self._identity_token) == 32: + self._send_packet(CUSTOM_PAYLOAD, + build_custom_payload(IDENTITY_TOKEN_RESPONSE, self._identity_token)) + self._log(f"Bot '{self._bot.name}' sent identity token response") + else: + self._send_packet(CUSTOM_PAYLOAD, + build_custom_payload(IDENTITY_TOKEN_RESPONSE)) + self._log(f"Bot '{self._bot.name}' sent empty identity token response") + + def _send_packet(self, packet_id: int, payload: bytes): + raw = frame_packet(packet_id, payload) + if self._send_cipher: + header = raw[:4] + encrypted = self._send_cipher.process(raw[4:]) + raw = header + encrypted + try: + if self._sock: + self._sock.sendall(raw) + except OSError: + pass + + def _recv_exact(self, n: int) -> bytes: + data = b"" + while len(data) < n: + chunk = self._sock.recv(n - len(data)) + if not chunk: + raise ConnectionError("Connection closed during recv") + data += chunk + return data + + +class BotManager(QObject): + """Manages bot player connections for load testing.""" + + bot_added = Signal(str) + bot_removed = Signal(str) + bot_error = Signal(str, str) + log = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._bots: dict[str, BotState] = {} + self._lock = threading.Lock() + self._next_id = 1 + + @property + def bot_count(self) -> int: + with self._lock: + return len(self._bots) + + def add_bot(self, host: str, port: int) -> str: + with self._lock: + name = f"Bot{self._next_id}" + self._next_id += 1 + xuid = secrets.randbits(62) | (1 << 32) + + bot = BotState(name=name) + bot.running = True + bot.thread = threading.Thread( + target=self._run_bot, args=(bot, host, port, xuid), + daemon=True) + + with self._lock: + self._bots[name] = bot + + bot.thread.start() + return name + + def remove_bot(self) -> str | None: + with self._lock: + if not self._bots: + return None + name = list(self._bots.keys())[-1] + bot = self._bots.pop(name) + + bot.running = False + if bot.sock: + try: + bot.sock.close() + except OSError: + pass + self.bot_removed.emit(name) + self.log.emit(f"Bot '{name}' disconnecting") + return name + + def remove_all(self): + with self._lock: + names = list(self._bots.keys()) + for _ in names: + self.remove_bot() + + def _run_bot(self, bot: BotState, host: str, port: int, xuid: int): + try: + def on_connected(): + self.bot_added.emit(bot.name) + + conn = BotConnection(bot, host, port, xuid, self.log.emit, on_connected) + conn.run() + except Exception as e: + self.bot_error.emit(bot.name, str(e)) + self.log.emit(f"Bot '{bot.name}' error: {e}") + finally: + bot.running = False + bot.connected = False + if bot.sock: + try: + bot.sock.close() + except OSError: + pass + bot.sock = None + with self._lock: + self._bots.pop(bot.name, None) diff --git a/tools/performance-monitor/build-dll.bat b/tools/performance-monitor/build-dll.bat new file mode 100644 index 00000000..c1e911c6 --- /dev/null +++ b/tools/performance-monitor/build-dll.bat @@ -0,0 +1,25 @@ +@echo off +echo Building perf-monitor.dll... +cd /d "%~dp0dll" + +if not exist build mkdir build +cd build + +cmake .. -G "Visual Studio 17 2022" -A x64 +if errorlevel 1 ( + echo CMake configure failed! + pause + exit /b 1 +) + +cmake --build . --config Release +if errorlevel 1 ( + echo Build failed! + pause + exit /b 1 +) + +echo. +echo Build successful! +echo DLL: %cd%\Release\perf-monitor.dll +pause diff --git a/tools/performance-monitor/connection.py b/tools/performance-monitor/connection.py new file mode 100644 index 00000000..fb609e56 --- /dev/null +++ b/tools/performance-monitor/connection.py @@ -0,0 +1,157 @@ +""" +TCP client for the injected performance monitor DLL. + +Connects to the DLL's TCP server, reads length-prefixed JSON frames, +parses them into model objects, and emits Qt signals. +""" + +import json +import logging +import socket +import struct +import threading +import time + +from PySide6.QtCore import QObject, Signal + +from models import ( + TickSnapshot, AutosaveSnapshot, + parse_tick, parse_autosave, +) + +logger = logging.getLogger("perfmon") + + +class PerfConnection(QObject): + """Manages TCP connection to the injected DLL's metrics server.""" + + connected = Signal(dict) # hello_ack payload + disconnected = Signal(str) # reason + tick_received = Signal(object) # TickSnapshot + autosave_received = Signal(object) # AutosaveSnapshot + error = Signal(str) + log = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._sock: socket.socket | None = None + self._thread: threading.Thread | None = None + self._running = False + + @property + def is_connected(self) -> bool: + return self._running and self._sock is not None + + def connect_to(self, host: str, port: int): + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._run, args=(host, port), daemon=True + ) + self._thread.start() + + def disconnect(self): + self._running = False + if self._sock: + try: + self._sock.close() + except OSError: + pass + + def _run(self, host: str, port: int): + try: + self._do_connect(host, port) + except Exception as e: + self.error.emit(str(e)) + finally: + self._running = False + if self._sock: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + def _do_connect(self, host: str, port: int): + self.log.emit(f"Connecting to {host}:{port}...") + self._sock = socket.create_connection((host, port), timeout=10) + self._sock.settimeout(2.0) + + self.log.emit("Connected, waiting for hello_ack...") + + # Read frames in a loop + recv_buf = bytearray() + + while self._running: + try: + chunk = self._sock.recv(65536) + except socket.timeout: + continue + except OSError: + break + + if not chunk: + break + + recv_buf.extend(chunk) + + # Process complete frames + while len(recv_buf) >= 4: + payload_len = struct.unpack(">I", recv_buf[:4])[0] + + if payload_len > 1_000_000: + raw_hex = recv_buf[:min(32, len(recv_buf))].hex() + self.error.emit( + f"Frame too large: {payload_len} bytes " + f"(0x{payload_len:08X}). Raw: {raw_hex} -- " + f"wrong port? DLL listens on 19800 by default" + ) + self._running = False + break + + total = 4 + payload_len + if len(recv_buf) < total: + break + + json_bytes = bytes(recv_buf[4:total]) + del recv_buf[:total] + + try: + data = json.loads(json_bytes) + except json.JSONDecodeError as e: + logger.warning("Invalid JSON frame: %s", e) + continue + + self._dispatch(data) + + self.log.emit("Connection lost") + self.disconnected.emit("Connection closed") + time.sleep(0.1) + + def _dispatch(self, data: dict): + msg_type = data.get("type", "") + + if msg_type == "hello_ack": + self.log.emit( + f"Server: {data.get('level_count', '?')} levels, " + f"target TPS {data.get('server_tps_target', 20)}" + ) + self.connected.emit(data) + + elif msg_type == "tick": + try: + snap = parse_tick(data) + self.tick_received.emit(snap) + except Exception as e: + logger.warning("Failed to parse tick: %s", e) + + elif msg_type == "autosave": + try: + snap = parse_autosave(data) + self.autosave_received.emit(snap) + except Exception as e: + logger.warning("Failed to parse autosave: %s", e) + + else: + self.log.emit(f"Unknown message type: {msg_type}") diff --git a/tools/performance-monitor/dll/CMakeLists.txt b/tools/performance-monitor/dll/CMakeLists.txt new file mode 100644 index 00000000..3222aaf8 --- /dev/null +++ b/tools/performance-monitor/dll/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.20) +project(PerfMonitor LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_library(perf-monitor SHARED + src/dllmain.cpp + src/hooks.cpp + src/symbols.cpp + src/metrics.cpp + src/tcp_server.cpp + src/json_writer.cpp +) + +target_include_directories(perf-monitor PRIVATE src) + +target_link_libraries(perf-monitor PRIVATE + dbghelp # PDB symbol resolution + ws2_32 # Winsock TCP server + psapi # GetProcessMemoryInfo +) + +target_compile_definitions(perf-monitor PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX + _CRT_SECURE_NO_WARNINGS +) + +# Use /MT to match the server's static CRT (MultiThreaded / MultiThreadedDebug) +set_property(TARGET perf-monitor PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" +) diff --git a/tools/performance-monitor/dll/src/dllmain.cpp b/tools/performance-monitor/dll/src/dllmain.cpp new file mode 100644 index 00000000..d7c52d98 --- /dev/null +++ b/tools/performance-monitor/dll/src/dllmain.cpp @@ -0,0 +1,172 @@ +// +// dllmain.cpp -- DLL entry point. +// +// When injected into Minecraft.Server.exe: +// 1. Resolve MinecraftServer::tick() via PDB symbols +// 2. Install a trampoline hook on tick() +// 3. Start a TCP server for the GUI to connect to +// +// On unload (FreeLibrary or process exit): +// 1. Remove the hook (restore original bytes) +// 2. Stop the TCP server +// + +#include "perf_monitor.h" +#include +#include + +// ----------------------------------------------------------------------- +// Globals +// ----------------------------------------------------------------------- + +std::atomic g_hooksInstalled{false}; +std::atomic g_clientConnected{false}; + +static HMODULE g_hModule = nullptr; +static HANDLE g_initThread = nullptr; + +static const int DEFAULT_PORT = 19800; +ResolvedSymbols g_symbols = {}; + +// ----------------------------------------------------------------------- +// Logging +// ----------------------------------------------------------------------- + +static FILE *g_logFile = nullptr; +static std::mutex g_logMtx; + +void LogInit(HMODULE hModule) +{ + char path[MAX_PATH]; + GetModuleFileNameA(hModule, path, MAX_PATH); + + // Replace DLL name with log name + char *sep = strrchr(path, '\\'); + if (sep) { + strcpy(sep + 1, "perf-monitor.log"); + } else { + strcpy(path, "perf-monitor.log"); + } + + g_logFile = fopen(path, "w"); +} + +void LogWrite(const char *fmt, ...) +{ + if (!g_logFile) return; + + std::lock_guard lock(g_logMtx); + + // Timestamp + SYSTEMTIME st; + GetLocalTime(&st); + fprintf(g_logFile, "%02d:%02d:%02d.%03d ", + st.wHour, st.wMinute, st.wSecond, st.wMilliseconds); + + va_list args; + va_start(args, fmt); + vfprintf(g_logFile, fmt, args); + va_end(args); + + fprintf(g_logFile, "\n"); + fflush(g_logFile); +} + +void LogShutdown() +{ + if (g_logFile) { + fclose(g_logFile); + g_logFile = nullptr; + } +} + +// ----------------------------------------------------------------------- +// Initialization thread (runs after injection) +// ----------------------------------------------------------------------- + +static DWORD WINAPI InitThread(LPVOID) +{ + LogWrite("perf-monitor.dll loaded into PID %u", GetCurrentProcessId()); + + // Get the base address of the main executable module + HMODULE hExe = GetModuleHandleA(nullptr); + if (!hExe) { + LogWrite("FATAL: GetModuleHandle(NULL) failed"); + return 1; + } + uintptr_t moduleBase = reinterpret_cast(hExe); + LogWrite("Module base: 0x%llx", (unsigned long long)moduleBase); + + // Resolve symbols from PDB + ResolvedSymbols syms{}; + HANDLE hProcess = GetCurrentProcess(); + + if (!ResolveSymbols(hProcess, moduleBase, syms)) { + LogWrite("FATAL: Symbol resolution failed"); + return 1; + } + + g_symbols = syms; + LogWrite("MinecraftServer::tick() at 0x%llx", (unsigned long long)syms.MinecraftServer_tick); + LogWrite("MinecraftServer::getInstance() at 0x%llx", (unsigned long long)syms.MinecraftServer_getInstance); + LogWrite("ServerLevel::getChunkMap() at 0x%llx", (unsigned long long)syms.ServerLevel_getChunkMap); + + // Initialize metrics subsystem + MetricsInit(); + + // Install the tick hook + if (!InstallTickHook(syms.MinecraftServer_tick)) { + LogWrite("FATAL: Hook installation failed"); + return 1; + } + LogWrite("Tick hook installed"); + + // Start TCP server + if (!TcpServerStart(DEFAULT_PORT)) { + LogWrite("FATAL: TCP server failed to start on port %d", DEFAULT_PORT); + RemoveTickHook(); + return 1; + } + LogWrite("TCP server listening on port %d", DEFAULT_PORT); + + return 0; +} + +// ----------------------------------------------------------------------- +// DllMain +// ----------------------------------------------------------------------- + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID) +{ + switch (reason) { + case DLL_PROCESS_ATTACH: + DisableThreadLibraryCalls(hModule); + g_hModule = hModule; + LogInit(hModule); + + // Spawn initialization on a separate thread so DllMain returns + // quickly (loader lock). + g_initThread = CreateThread(nullptr, 0, InitThread, nullptr, 0, nullptr); + break; + + case DLL_PROCESS_DETACH: + LogWrite("DLL detaching..."); + + // Wait for init to finish before tearing down + if (g_initThread) { + WaitForSingleObject(g_initThread, 5000); + CloseHandle(g_initThread); + g_initThread = nullptr; + } + + TcpServerStop(); + RemoveTickHook(); + MetricsShutdown(); + + LogWrite("Cleanup complete"); + LogShutdown(); + break; + } + + return TRUE; +} diff --git a/tools/performance-monitor/dll/src/hooks.cpp b/tools/performance-monitor/dll/src/hooks.cpp new file mode 100644 index 00000000..af6e124b --- /dev/null +++ b/tools/performance-monitor/dll/src/hooks.cpp @@ -0,0 +1,337 @@ +// +// hooks.cpp -- Trampoline hook on MinecraftServer::tick(). +// +// Overwrites the first 14 bytes of tick() with JMP to HookedTick. +// The trampoline contains the saved prologue bytes + JMP back to tick+N. +// +// The previous crash was caused by a server autosave bug, not the +// trampoline. With that fixed, this approach is stable. +// + +#include "perf_monitor.h" +#include +#include + +// ----------------------------------------------------------------------- +// State +// ----------------------------------------------------------------------- + +static constexpr int HOOK_SIZE = 14; // FF 25 00 00 00 00 + 8-byte addr + +static uint8_t g_originalBytes[32] = {}; +static uintptr_t g_tickAddr = 0; +static uint8_t *g_trampoline = nullptr; +static int g_prologueLen = 0; + +static int64_t g_qpcFreq = 0; + +using TickFn = void(__fastcall *)(void *thisPtr); + +// ----------------------------------------------------------------------- +// Timing helpers +// ----------------------------------------------------------------------- + +static inline int64_t NowUs() +{ + LARGE_INTEGER li; + QueryPerformanceCounter(&li); + return (li.QuadPart * 1000000) / g_qpcFreq; +} + +static inline int64_t NowMs() +{ + FILETIME ft; + GetSystemTimeAsFileTime(&ft); + ULARGE_INTEGER uli; + uli.LowPart = ft.dwLowDateTime; + uli.HighPart = ft.dwHighDateTime; + return (int64_t)((uli.QuadPart - 116444736000000000ULL) / 10000ULL); +} + +// ----------------------------------------------------------------------- +// Instruction length decoder +// ----------------------------------------------------------------------- + +static int GetInstructionLength(const uint8_t *ip) +{ + const uint8_t *p = ip; + + // Skip prefixes + while ((*p >= 0x40 && *p <= 0x4F) || *p == 0x66 || *p == 0x67 || + *p == 0xF2 || *p == 0xF3) { + p++; + } + + uint8_t op = *p++; + + // Simple 1-byte opcodes + if (op == 0x90 || op == 0xC3 || op == 0xCC) return (int)(p - ip); + if (op >= 0x50 && op <= 0x5F) return (int)(p - ip); + + // MOV reg, imm + if (op >= 0xB8 && op <= 0xBF) { + bool rexW = (ip[0] >= 0x48 && ip[0] <= 0x4F); + return (int)(p - ip) + (rexW ? 8 : 4); + } + + // Two-byte escape + if (op == 0x0F) { p++; } + + // ModRM-based instructions + { + uint8_t modrm = *p++; + uint8_t mod = (modrm >> 6) & 3; + uint8_t rm = modrm & 7; + + if (rm == 4 && mod != 3) p++; // SIB + + if (mod == 0 && rm == 5) p += 4; // [rip+disp32] + else if (mod == 1) p += 1; // [reg+disp8] + else if (mod == 2) p += 4; // [reg+disp32] + + // Group opcodes with immediate + uint8_t base_op = ip[(p - ip > 2 && (ip[0] >= 0x40 && ip[0] <= 0x4F)) ? 1 : 0]; + if (base_op == 0x81) p += 4; + else if (base_op == 0x80 || base_op == 0x83) p += 1; + } + + int len = (int)(p - ip); + return (len > 0) ? len : 1; +} + +static int CopyPrologue(const uint8_t *src, uint8_t *dst, int minBytes) +{ + int copied = 0; + while (copied < minBytes) { + int len = GetInstructionLength(src + copied); + memcpy(dst + copied, src + copied, len); + copied += len; + if (copied > 48) break; + } + return copied; +} + +// ----------------------------------------------------------------------- +// The hook function +// ----------------------------------------------------------------------- + +static int g_tickCounter = 0; +static double g_memUsedMb = 0.0; +static double g_memTotalMb = 0.0; +static int g_memUpdateCounter = 0; + +// SEH-safe level reader (separate from player count to avoid one killing the other) +static void ReadLevels(void *thisPtr, std::vector &out) +{ + LevelMetrics localLevels[3] = {}; + uint32_t levelCount = 0; + + __try { + auto base = reinterpret_cast(thisPtr); + auto levelsData = *reinterpret_cast(base + 24); + auto levelsLength = *reinterpret_cast(base + 32); + + if (levelsData && levelsLength > 0 && levelsLength <= 3) { + levelCount = levelsLength; + for (uint32_t i = 0; i < levelsLength; i++) { + localLevels[i].dimension = (i == 0) ? 0 : (i == 1) ? -1 : 1; + if (levelsData[i]) { + CollectLevelMetrics(levelsData[i], i, localLevels[i]); + } + } + } + } + __except (EXCEPTION_EXECUTE_HANDLER) { + static bool logged = false; + if (!logged) { + LogWrite("WARNING: Exception reading level data"); + logged = true; + } + return; + } + + for (uint32_t i = 0; i < levelCount; i++) { + out.push_back(localLevels[i]); + } +} + + +// Track the time between tick() calls to measure true TPS. +// When autosave (or anything else in run()) stalls, the next tick is +// delayed, and the interval grows beyond 50ms. +static int64_t g_lastTickStartUs = 0; +static int64_t g_tickIntervalUs = 50000; // default = 50ms = 20 TPS +static bool g_skipSnapshot = false; // suppress catch-up ticks + +static void __fastcall HookedTick(void *thisPtr) +{ + auto originalTick = reinterpret_cast(g_trampoline); + + int64_t tickStart = NowUs(); + int64_t rawInterval = 50000; + if (g_lastTickStartUs > 0) { + rawInterval = tickStart - g_lastTickStartUs; + } + g_lastTickStartUs = tickStart; + + // Detect catch-up ticks: after a stall, run() fires tick() rapidly + // to burn through accumulated unprocessedTime. These have intervals + // well below 50ms. We still call tick() but don't report them to + // the GUI -- they'd flood the TPS average with fake "20 TPS" readings. + // Only the stall tick itself (interval > 50ms) gets reported. + if (rawInterval < 25000) { + // Catch-up tick: run it but don't emit a snapshot + g_skipSnapshot = true; + } else { + g_tickIntervalUs = rawInterval; + g_skipSnapshot = false; + } + + if (!g_clientConnected.load(std::memory_order_relaxed)) { + originalTick(thisPtr); + return; + } + + originalTick(thisPtr); + + // Skip catch-up ticks - only emit real-time ticks to the GUI + if (g_skipSnapshot) return; + + int64_t tickEnd = NowUs(); + int64_t workUs = tickEnd - tickStart; + + g_tickCounter++; + + g_memUpdateCounter++; + if (g_memUpdateCounter >= 20) { + g_memUpdateCounter = 0; + CollectMemoryMetrics(g_memUsedMb, g_memTotalMb); + } + + std::vector levelMetrics; + ReadLevels(thisPtr, levelMetrics); + int totalPlayers = 0; + for (auto &lm : levelMetrics) totalPlayers += lm.playerCount; + + TickSnapshot snap{}; + snap.tick = g_tickCounter; + snap.timestampMs = NowMs(); + snap.totalUs = g_tickIntervalUs; // tick-to-tick interval (for TPS) + snap.poolResetUs = workUs; // tick() work time (for MSPT) + snap.levels = std::move(levelMetrics); + snap.playersTickUs = 0; + snap.connectionTickUs = 0; + snap.consoleTickUs = 0; + snap.totalPlayers = totalPlayers; + snap.memoryUsedMb = g_memUsedMb; + snap.memoryTotalMb = g_memTotalMb; + snap.isAutosaving = false; + snap.isPaused = false; + + PushTickSnapshot(std::move(snap)); +} + +// ----------------------------------------------------------------------- +// Hook installation +// ----------------------------------------------------------------------- + +bool InstallTickHook(uintptr_t tickAddr) +{ + LARGE_INTEGER freq; + QueryPerformanceFrequency(&freq); + g_qpcFreq = freq.QuadPart; + + g_tickAddr = tickAddr; + + // Log first 32 bytes of tick() + { + const uint8_t *p = reinterpret_cast(tickAddr); + char hex[128]; + int pos = 0; + for (int i = 0; i < 32 && pos < 120; i++) + pos += snprintf(hex + pos, 128 - pos, "%02X ", p[i]); + LogWrite("tick() prologue: %s", hex); + } + + memcpy(g_originalBytes, reinterpret_cast(tickAddr), 32); + + // Allocate trampoline + g_trampoline = static_cast( + VirtualAlloc(nullptr, 128, MEM_COMMIT | MEM_RESERVE, + PAGE_EXECUTE_READWRITE)); + if (!g_trampoline) { + LogWrite("VirtualAlloc failed: 0x%08x", GetLastError()); + return false; + } + + // Copy prologue + g_prologueLen = CopyPrologue( + reinterpret_cast(tickAddr), + g_trampoline, HOOK_SIZE); + LogWrite("Copied %d prologue bytes", g_prologueLen); + + // Log decoded instructions + { + const uint8_t *p = reinterpret_cast(tickAddr); + int off = 0; + while (off < g_prologueLen) { + int len = GetInstructionLength(p + off); + char hex[64]; int hpos = 0; + for (int i = 0; i < len && hpos < 60; i++) + hpos += snprintf(hex + hpos, 64 - hpos, "%02X ", p[off + i]); + LogWrite(" +%d: [%d] %s", off, len, hex); + off += len; + } + } + + // Append JMP back to tick + prologueLen + uint8_t *jmpBack = g_trampoline + g_prologueLen; + uintptr_t resumeAddr = tickAddr + g_prologueLen; + jmpBack[0] = 0xFF; + jmpBack[1] = 0x25; + *reinterpret_cast(jmpBack + 2) = 0; + *reinterpret_cast(jmpBack + 6) = resumeAddr; + + // Patch tick() entry to JMP to HookedTick + DWORD oldProtect; + if (!VirtualProtect(reinterpret_cast(tickAddr), HOOK_SIZE, + PAGE_EXECUTE_READWRITE, &oldProtect)) { + LogWrite("VirtualProtect failed: 0x%08x", GetLastError()); + VirtualFree(g_trampoline, 0, MEM_RELEASE); + g_trampoline = nullptr; + return false; + } + + uint8_t *patch = reinterpret_cast(tickAddr); + patch[0] = 0xFF; + patch[1] = 0x25; + *reinterpret_cast(patch + 2) = 0; + *reinterpret_cast(patch + 6) = reinterpret_cast(&HookedTick); + + VirtualProtect(reinterpret_cast(tickAddr), HOOK_SIZE, oldProtect, &oldProtect); + FlushInstructionCache(GetCurrentProcess(), reinterpret_cast(tickAddr), HOOK_SIZE); + + g_hooksInstalled.store(true, std::memory_order_release); + return true; +} + +void RemoveTickHook() +{ + if (!g_hooksInstalled.load()) return; + + DWORD oldProtect; + VirtualProtect(reinterpret_cast(g_tickAddr), 32, + PAGE_EXECUTE_READWRITE, &oldProtect); + memcpy(reinterpret_cast(g_tickAddr), g_originalBytes, g_prologueLen); + VirtualProtect(reinterpret_cast(g_tickAddr), 32, + oldProtect, &oldProtect); + FlushInstructionCache(GetCurrentProcess(), reinterpret_cast(g_tickAddr), 32); + + if (g_trampoline) { + VirtualFree(g_trampoline, 0, MEM_RELEASE); + g_trampoline = nullptr; + } + + g_hooksInstalled.store(false, std::memory_order_release); + LogWrite("Tick hook removed"); +} diff --git a/tools/performance-monitor/dll/src/json_writer.cpp b/tools/performance-monitor/dll/src/json_writer.cpp new file mode 100644 index 00000000..7592f589 --- /dev/null +++ b/tools/performance-monitor/dll/src/json_writer.cpp @@ -0,0 +1,132 @@ +// +// json_writer.cpp -- Hand-rolled JSON serialization for metric snapshots. +// +// No external JSON library dependency. The schema is fixed and simple +// enough that snprintf is cleaner than pulling in nlohmann/json. +// + +#include "perf_monitor.h" +#include + +// Stack buffer size for JSON assembly. A typical tick snapshot is +// 500-1000 bytes. Autosave snapshots are smaller. +static constexpr int BUF_SIZE = 4096; + +std::string SerializeTick(const TickSnapshot &s) +{ + char buf[BUF_SIZE]; + int pos = 0; + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "{\"type\":\"tick\"," + "\"tick\":%d," + "\"timestamp_ms\":%lld," + "\"total_us\":%lld," + "\"phases\":{", + s.tick, + (long long)s.timestampMs, + (long long)s.totalUs); + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "\"pool_reset_us\":%lld,", + (long long)s.poolResetUs); + + // Levels array + pos += snprintf(buf + pos, BUF_SIZE - pos, "\"levels\":["); + for (size_t i = 0; i < s.levels.size(); i++) { + const auto &lm = s.levels[i]; + if (i > 0) buf[pos++] = ','; + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "{" + "\"dimension\":%d," + "\"level_tick_us\":%lld," + "\"entity_tick_us\":%lld," + "\"entity_tick_skipped\":%s," + "\"tracker_tick_us\":%lld," + "\"entity_count\":%d," + "\"global_entity_count\":%d," + "\"player_count\":%d," + "\"loaded_chunks\":%d," + "\"entities_to_remove\":%d," + "\"tile_entity_count\":%d" + "}", + lm.dimension, + (long long)lm.levelTickUs, + (long long)lm.entityTickUs, + lm.entityTickSkipped ? "true" : "false", + (long long)lm.trackerTickUs, + lm.entityCount, + lm.globalEntityCount, + lm.playerCount, + lm.loadedChunks, + lm.entitiesToRemove, + lm.tileEntityCount); + } + pos += snprintf(buf + pos, BUF_SIZE - pos, "],"); + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "\"players_tick_us\":%lld," + "\"connection_tick_us\":%lld," + "\"console_tick_us\":%lld" + "},", + (long long)s.playersTickUs, + (long long)s.connectionTickUs, + (long long)s.consoleTickUs); + + pos += snprintf(buf + pos, BUF_SIZE - pos, + "\"server\":{" + "\"total_players\":%d," + "\"memory_used_mb\":%.1f," + "\"memory_total_mb\":%.1f," + "\"is_autosaving\":%s," + "\"is_paused\":%s," + "\"tps_target\":20" + "}}", + s.totalPlayers, + s.memoryUsedMb, + s.memoryTotalMb, + s.isAutosaving ? "true" : "false", + s.isPaused ? "true" : "false"); + + return std::string(buf, pos); +} + +std::string SerializeAutosave(const AutosaveSnapshot &s) +{ + char buf[BUF_SIZE]; + int pos = snprintf(buf, BUF_SIZE, + "{\"type\":\"autosave\"," + "\"tick\":%d," + "\"timestamp_ms\":%lld," + "\"total_us\":%lld," + "\"breakdown\":{" + "\"players_us\":%lld," + "\"levels_us\":%lld," + "\"rules_us\":%lld," + "\"flush_us\":%lld" + "}}", + s.tick, + (long long)s.timestampMs, + (long long)s.totalUs, + (long long)s.playersUs, + (long long)s.levelsUs, + (long long)s.rulesUs, + (long long)s.flushUs); + + return std::string(buf, pos); +} + +std::string SerializeHelloAck(int levelCount) +{ + char buf[256]; + int pos = snprintf(buf, 256, + "{\"type\":\"hello_ack\"," + "\"version\":1," + "\"server_tps_target\":20," + "\"level_count\":%d," + "\"dimensions\":[0,-1,1]}", + levelCount); + + return std::string(buf, pos); +} diff --git a/tools/performance-monitor/dll/src/metrics.cpp b/tools/performance-monitor/dll/src/metrics.cpp new file mode 100644 index 00000000..8d2108bd --- /dev/null +++ b/tools/performance-monitor/dll/src/metrics.cpp @@ -0,0 +1,384 @@ +// +// metrics.cpp -- Server metric collection and snapshot queuing. +// +// Reads entity counts, chunk counts, player counts, and memory usage +// from the server's live data structures. +// + +#include "perf_monitor.h" +#include +#include + +// ----------------------------------------------------------------------- +// Snapshot queues (game thread pushes, TCP thread drains) +// ----------------------------------------------------------------------- + +static std::mutex g_tickMtx; +static std::deque g_tickQueue; +static constexpr size_t MAX_TICK_QUEUE = 200; // ~10 seconds at 20 TPS + +static std::mutex g_autoMtx; +static std::deque g_autoQueue; +static constexpr size_t MAX_AUTO_QUEUE = 20; + +// ----------------------------------------------------------------------- +// Initialization +// ----------------------------------------------------------------------- + +void MetricsInit() +{ +} + +void MetricsShutdown() +{ + std::lock_guard lock1(g_tickMtx); + g_tickQueue.clear(); + std::lock_guard lock2(g_autoMtx); + g_autoQueue.clear(); +} + +// ----------------------------------------------------------------------- +// Level metrics collection +// +// Level layout on MSVC x64, derived from Level.h and confirmed by +// runtime vector scan (see perf-monitor.log for the calibration output): +// +// offset 0: vtable ptr (from LevelSource) 8 bytes +// offset 8: seaLevel (int) 4 bytes +// offset 12: padding 4 bytes +// offset 16: m_entitiesCS (CRITICAL_SECTION) 40 bytes +// offset 56: padding to 8-byte alignment 8 bytes +// offset 64: entities (vector>) 24 bytes +// offset 88: entitiesToRemove (vector>) 24 bytes +// offset 112: m_bDisableAddNewTileEntities (bool) 1 byte +// offset 113: padding 7 bytes +// offset 120: m_tileEntityListCS (CRITICAL_SECTION) 40 bytes +// offset 160: tileEntityList (vector>) 24 bytes +// offset 184: pendingTileEntities (vector) 24 bytes +// offset 208: tileEntitiesToUnload (vector) 24 bytes +// offset 232: updatingTileEntities (bool) 1 byte +// offset 233: padding 7 bytes +// offset 240: players (vector>) 24 bytes +// offset 264: globalEntities (vector>) 24 bytes +// +// These offsets are validated against a live debug server. If they shift +// in a future build, the SEH guards will catch bad reads and the log will +// show -1 for the affected counts. Re-run the offset scan to recalibrate. +// ----------------------------------------------------------------------- + +// std::vector> on MSVC x64: +// +0: _Myfirst (pointer to first element) +// +8: _Mylast (pointer past last element) +// +16: _Myend (pointer past allocated capacity) +// sizeof(shared_ptr) = 16 (raw ptr + ref count ptr) + +static int ReadVectorSize(uint8_t *vecBase, int elementSize) +{ + auto first = *reinterpret_cast(vecBase + 0); + auto last = *reinterpret_cast(vecBase + 8); + + if (!first || !last || last < first) return 0; + + auto diff = last - first; + if (elementSize <= 0) return 0; + + int count = (int)(diff / elementSize); + + // Sanity: reject absurd values (corrupted memory) + if (count < 0 || count > 100000) return 0; + + return count; +} + +// Level member offsets -- calibrated from runtime scan. +// If these are wrong for a given build, the SEH guards return -1 and the +// log records the failure. Rerun with a fresh scan to recalibrate. +// Level member offsets differ between Debug and Release builds because +// CRITICAL_SECTION is 48 bytes in Debug (extra padding) vs 40 in Release. +// We auto-detect by checking where the entities vector is (the first +// reliably identifiable vector in the object). +// +// Debug layout (CS=48): entities=+64, tileEntityList=+176, players=+280, globalEntities=+256 +// Release layout (CS=40): entities=+56, tileEntityList=+152, players=+232, globalEntities=+256 +namespace LevelOff { + static int entities = 0; + static int entitiesToRemove = 0; + static int tileEntityList = 0; + static int players = 0; + static int globalEntities = 0; + static bool detected = false; + + static void Detect(int entitiesOffset) + { + if (detected) return; + detected = true; + + if (entitiesOffset == 64) { + // Debug build (CS=48 with extra padding) + entities = 64; + entitiesToRemove = 88; + tileEntityList = 176; + players = 280; + globalEntities = 256; + LogWrite("Level offsets: DEBUG layout (entities=+64)"); + } else if (entitiesOffset == 56) { + // Release build (CS=40) + entities = 56; + entitiesToRemove = 80; + tileEntityList = 152; + players = 232; + globalEntities = 256; + LogWrite("Level offsets: RELEASE layout (entities=+56)"); + } else { + // Unknown layout - use the detected entities offset and estimate + entities = entitiesOffset; + LogWrite("Level offsets: UNKNOWN layout (entities=+%d), entity count only", entitiesOffset); + } + } +} + +// Scan for offset calibration -- writes to the log so we can update the +// constants above if the layout changes. Called once on first tick. +static bool g_scanDone = false; + +// SEH-safe inner scan (no C++ objects with destructors) +struct VecCandidate { int offset; int count; }; +static constexpr int MAX_SCAN_CANDIDATES = 64; +static VecCandidate g_scanResults[MAX_SCAN_CANDIDATES]; +static int g_scanCount = 0; + +static void ScanLevelOffsetsInner(void *serverLevel) +{ + auto base = reinterpret_cast(serverLevel); + g_scanCount = 0; + + __try { + for (int off = 0; off < 400 && g_scanCount < MAX_SCAN_CANDIDATES; off += 8) { + auto first = *reinterpret_cast(base + off); + auto last = *reinterpret_cast(base + off + 8); + auto end = *reinterpret_cast(base + off + 16); + + if (!first && !last && !end) continue; + + auto ok = [](uintptr_t p) -> bool { + return p == 0 || (p > 0x10000 && p < 0x7FFFFFFFFFFF); + }; + if (!ok(first) || !ok(last) || !ok(end)) continue; + if (first > last || last > end) continue; + + // Try both shared_ptr (16 bytes) and raw ptr (8 bytes) + int count16 = 0, count8 = 0; + if (first && last > first) { + auto diff = last - first; + if (diff % 16 == 0) count16 = (int)(diff / 16); + if (diff % 8 == 0) count8 = (int)(diff / 8); + } + if (count16 > 100000) count16 = -1; + if (count8 > 100000) count8 = -1; + + // Use shared_ptr count as primary, store raw count for logging + g_scanResults[g_scanCount++] = {off, count16}; + } + } + __except (EXCEPTION_EXECUTE_HANDLER) { + LogWrite("WARNING: Exception during Level offset scan"); + } +} + +// Extended scan: log with both element size interpretations +static void ScanLevelOffsetsExtended(void *serverLevel) +{ + auto base = reinterpret_cast(serverLevel); + + __try { + for (int off = 56; off < 320; off += 8) { + auto first = *reinterpret_cast(base + off); + auto last = *reinterpret_cast(base + off + 8); + auto end = *reinterpret_cast(base + off + 16); + + if (!first && !last && !end) continue; + + auto ok = [](uintptr_t p) -> bool { + return p == 0 || (p > 0x10000 && p < 0x7FFFFFFFFFFF); + }; + if (!ok(first) || !ok(last) || !ok(end)) continue; + if (first > last || last > end) continue; + + auto diff = last - first; + LogWrite(" +%d: diff=%llu /16=%llu /8=%llu /24=%llu", + off, (unsigned long long)diff, + diff / 16, diff / 8, diff / 24); + } + } + __except (EXCEPTION_EXECUTE_HANDLER) { + LogWrite("WARNING: Exception during extended scan"); + } +} + +static void ScanLevelOffsets(void *serverLevel) +{ + if (g_scanDone) return; + g_scanDone = true; + + ScanLevelOffsetsInner(serverLevel); + + LogWrite("Extended vector scan (diff in bytes, divided by element sizes):"); + ScanLevelOffsetsExtended(serverLevel); + + LogWrite("Level offset scan (%d candidates):", g_scanCount); + for (int i = 0; i < g_scanCount; i++) { + LogWrite(" +%d: %d elements", g_scanResults[i].offset, g_scanResults[i].count); + } + + // Auto-detect layout: find the first vector with a plausible entity count + // (10-5000 elements). This is the `entities` vector. + for (int i = 0; i < g_scanCount; i++) { + if (g_scanResults[i].count >= 10 && g_scanResults[i].count <= 5000) { + LevelOff::Detect(g_scanResults[i].offset); + break; + } + } + + if (LevelOff::detected) { + auto has = [](int off) { + for (int i = 0; i < g_scanCount; i++) + if (g_scanResults[i].offset == off) return true; + return false; + }; + LogWrite("Offset validation: entities=%s entitiesToRemove=%s tileEntityList=%s players=%s globalEntities=%s", + has(LevelOff::entities) ? "OK" : "MISS", + has(LevelOff::entitiesToRemove) ? "OK" : "MISS", + has(LevelOff::tileEntityList) ? "OK" : "MISS", + has(LevelOff::players) ? "OK" : "MISS", + has(LevelOff::globalEntities) ? "OK" : "MISS"); + } +} + +void CollectLevelMetrics(void *serverLevel, int index, LevelMetrics &out) +{ + if (!serverLevel) return; + + // Run offset scan once for logging/validation + if (!g_scanDone) { + ScanLevelOffsets(serverLevel); + } + + if (!LevelOff::detected) return; + + auto base = reinterpret_cast(serverLevel); + + __try { + out.entityCount = ReadVectorSize(base + LevelOff::entities, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.entityCount = -1; } + + __try { + out.globalEntityCount = ReadVectorSize(base + LevelOff::globalEntities, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.globalEntityCount = -1; } + + __try { + out.playerCount = ReadVectorSize(base + LevelOff::players, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.playerCount = -1; } + + __try { + out.tileEntityCount = ReadVectorSize(base + LevelOff::tileEntityList, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.tileEntityCount = -1; } + + __try { + out.entitiesToRemove = ReadVectorSize(base + LevelOff::entitiesToRemove, 16); + } + __except (EXCEPTION_EXECUTE_HANDLER) { out.entitiesToRemove = -1; } + + // Loaded chunk count via ServerLevel::getChunkMap() (Debug builds only). + // In Release builds, getChunkMap() is inlined and can't be resolved from PDB. + // Chunk count will show 0 on Release until we add PDB type info queries. + if (g_symbols.ServerLevel_getChunkMap) { + __try { + using GetChunkMapFn = void *(__fastcall *)(void *); + auto fn = reinterpret_cast(g_symbols.ServerLevel_getChunkMap); + auto chunkMap = reinterpret_cast(fn(serverLevel)); + if (chunkMap) { + static int chunkCountOffset = -1; + if (chunkCountOffset < 0) { + for (int off = 24; off < 120; off += 8) { + auto val = *reinterpret_cast(chunkMap + off); + if (val > 0 && val < 10000) { + chunkCountOffset = off; + LogWrite("PlayerChunkMap chunk count at +%d = %zu", off, val); + break; + } + } + if (chunkCountOffset < 0) chunkCountOffset = 0; + } + if (chunkCountOffset > 0) { + auto count = *reinterpret_cast(chunkMap + chunkCountOffset); + if (count < 10000) { + out.loadedChunks = (int)count; + } + } + } + } + __except (EXCEPTION_EXECUTE_HANDLER) { + out.loadedChunks = -1; + } + } +} + +// ----------------------------------------------------------------------- +// Memory metrics +// ----------------------------------------------------------------------- + +void CollectMemoryMetrics(double &usedMb, double &totalMb) +{ + PROCESS_MEMORY_COUNTERS pmc; + if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) { + usedMb = pmc.WorkingSetSize / (1024.0 * 1024.0); + } + + MEMORYSTATUSEX memInfo; + memInfo.dwLength = sizeof(memInfo); + if (GlobalMemoryStatusEx(&memInfo)) { + totalMb = memInfo.ullTotalPhys / (1024.0 * 1024.0); + } +} + +// ----------------------------------------------------------------------- +// Snapshot queuing +// ----------------------------------------------------------------------- + +void PushTickSnapshot(TickSnapshot &&snap) +{ + std::lock_guard lock(g_tickMtx); + if (g_tickQueue.size() >= MAX_TICK_QUEUE) { + g_tickQueue.pop_front(); + } + g_tickQueue.push_back(std::move(snap)); +} + +void PushAutosaveSnapshot(AutosaveSnapshot &&snap) +{ + std::lock_guard lock(g_autoMtx); + if (g_autoQueue.size() >= MAX_AUTO_QUEUE) { + g_autoQueue.pop_front(); + } + g_autoQueue.push_back(std::move(snap)); +} + +std::vector DrainTickSnapshots() +{ + std::lock_guard lock(g_tickMtx); + std::vector out(g_tickQueue.begin(), g_tickQueue.end()); + g_tickQueue.clear(); + return out; +} + +std::vector DrainAutosaveSnapshots() +{ + std::lock_guard lock(g_autoMtx); + std::vector out(g_autoQueue.begin(), g_autoQueue.end()); + g_autoQueue.clear(); + return out; +} diff --git a/tools/performance-monitor/dll/src/perf_monitor.h b/tools/performance-monitor/dll/src/perf_monitor.h new file mode 100644 index 00000000..8a43cfae --- /dev/null +++ b/tools/performance-monitor/dll/src/perf_monitor.h @@ -0,0 +1,143 @@ +#pragma once +// +// perf_monitor.h -- shared types and declarations for the injected +// performance-monitoring DLL. +// +// The DLL is injected into Minecraft.Server.exe at runtime. It uses +// DbgHelp to resolve private symbols from the PDB, installs a +// trampoline on MinecraftServer::tick(), and collects per-phase timing +// from inside the server process. Metrics are served as length-prefixed +// JSON over a lightweight TCP socket. +// + +#include +#include +#include +#include +#include +#include + +#include + +// ----------------------------------------------------------------------- +// Snapshot data -- produced on the game thread, consumed by TCP thread +// ----------------------------------------------------------------------- + +struct LevelMetrics { + int dimension; // 0 = Overworld, -1 = Nether, 1 = End + int64_t levelTickUs; // Level::tick() (weather) + int64_t entityTickUs; // Level::tickEntities() + bool entityTickSkipped; + int64_t trackerTickUs; // EntityTracker::tick() + int entityCount; + int globalEntityCount; + int playerCount; + int loadedChunks; + int entitiesToRemove; + int tileEntityCount; +}; + +struct TickSnapshot { + int tick; + int64_t timestampMs; + int64_t totalUs; + + int64_t poolResetUs; + std::vector levels; + int64_t playersTickUs; + int64_t connectionTickUs; + int64_t consoleTickUs; + + int totalPlayers; + double memoryUsedMb; + double memoryTotalMb; + bool isAutosaving; + bool isPaused; +}; + +struct AutosaveSnapshot { + int tick; + int64_t timestampMs; + int64_t totalUs; + int64_t playersUs; + int64_t levelsUs; + int64_t rulesUs; + int64_t flushUs; +}; + +// ----------------------------------------------------------------------- +// Global state +// ----------------------------------------------------------------------- + +// Set to true once hooks are installed; the tick wrapper checks this. +extern std::atomic g_hooksInstalled; + +// Set to true when at least one TCP client is connected. When false the +// tick wrapper still runs (it must -- it IS the tick) but skips all +// timing and metric collection. +extern std::atomic g_clientConnected; + +// ----------------------------------------------------------------------- +// symbols.cpp -- PDB symbol resolution +// ----------------------------------------------------------------------- + +struct ResolvedSymbols { + uintptr_t MinecraftServer_tick; // private void tick() + uintptr_t MinecraftServer_getInstance; // static MinecraftServer* getInstance() + uintptr_t ServerLevel_getChunkMap; // public PlayerChunkMap* getChunkMap() +}; + +bool ResolveSymbols(HANDLE hProcess, uintptr_t moduleBase, ResolvedSymbols &out); + +// Global resolved symbols (set during init, read from hooks/metrics) +extern ResolvedSymbols g_symbols; + +// ----------------------------------------------------------------------- +// hooks.cpp -- trampoline installation +// ----------------------------------------------------------------------- + +bool InstallTickHook(uintptr_t tickAddr); +void RemoveTickHook(); + +// ----------------------------------------------------------------------- +// metrics.cpp -- timing and data collection +// ----------------------------------------------------------------------- + +void MetricsInit(); +void MetricsShutdown(); + +// Called from inside the hooked tick to collect stats. +// The hook itself is in hooks.cpp; these helpers read server internals. +void CollectLevelMetrics(void *serverLevel, int index, LevelMetrics &out); +void CollectMemoryMetrics(double &usedMb, double &totalMb); + +// Push a completed snapshot to the outbound queue (thread-safe). +void PushTickSnapshot(TickSnapshot &&snap); +void PushAutosaveSnapshot(AutosaveSnapshot &&snap); + +// Pop all pending snapshots (called by TCP thread). +std::vector DrainTickSnapshots(); +std::vector DrainAutosaveSnapshots(); + +// ----------------------------------------------------------------------- +// tcp_server.cpp -- lightweight JSON-over-TCP server +// ----------------------------------------------------------------------- + +bool TcpServerStart(int port); +void TcpServerStop(); + +// ----------------------------------------------------------------------- +// json_writer.cpp -- serialization +// ----------------------------------------------------------------------- + +std::string SerializeTick(const TickSnapshot &s); +std::string SerializeAutosave(const AutosaveSnapshot &s); +std::string SerializeHelloAck(int levelCount); + +// ----------------------------------------------------------------------- +// Logging (writes to perf-monitor.log next to the DLL) +// ----------------------------------------------------------------------- + +void LogInit(HMODULE hModule); +void LogWrite(const char *fmt, ...); +void LogShutdown(); diff --git a/tools/performance-monitor/dll/src/symbols.cpp b/tools/performance-monitor/dll/src/symbols.cpp new file mode 100644 index 00000000..5a14f655 --- /dev/null +++ b/tools/performance-monitor/dll/src/symbols.cpp @@ -0,0 +1,120 @@ +// +// symbols.cpp -- Resolve private C++ symbols from the PDB using DbgHelp. +// +// Since we are injected into the process, we can use SymFromName() to +// look up mangled symbol names. The PDB must be next to the .exe or +// in the _NT_SYMBOL_PATH. +// + +#include "perf_monitor.h" + +#pragma warning(push) +#pragma warning(disable : 4091) // 'typedef ': ignored on left of '' when no variable is declared +#include +#pragma warning(pop) + +#pragma comment(lib, "dbghelp.lib") + +// ----------------------------------------------------------------------- +// Decorated (mangled) names for the symbols we need. +// +// These were determined from the PDB. If the server is rebuilt with +// different compiler settings the manglings may change, so we also +// fall back to undecorated name search. +// ----------------------------------------------------------------------- + +// MinecraftServer::tick() -- private void __cdecl MinecraftServer::tick(void) +// MSVC x64 mangling: ?tick@MinecraftServer@@AEAAXXZ +static const char *TICK_DECORATED = "?tick@MinecraftServer@@AEAAXXZ"; +static const char *TICK_UNDECORATED = "MinecraftServer::tick"; + +// MinecraftServer::getInstance() -- public static MinecraftServer* __cdecl MinecraftServer::getInstance(void) +// This is inlined in the header so may not exist as a symbol. +// Instead we resolve the static member MinecraftServer::server directly. +// ?server@MinecraftServer@@0PEAV1@EA +static const char *SERVER_PTR_DECORATED = "?server@MinecraftServer@@0PEAV1@EA"; +static const char *SERVER_PTR_UNDECORATED = "MinecraftServer::server"; + +// ----------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------- + +static bool TryResolve(HANDLE hProcess, const char *name, uintptr_t &outAddr) +{ + // SYMBOL_INFO needs extra space for the name + char buf[sizeof(SYMBOL_INFO) + MAX_SYM_NAME]; + SYMBOL_INFO *sym = reinterpret_cast(buf); + memset(buf, 0, sizeof(buf)); + sym->SizeOfStruct = sizeof(SYMBOL_INFO); + sym->MaxNameLen = MAX_SYM_NAME; + + if (SymFromName(hProcess, name, sym)) { + outAddr = static_cast(sym->Address); + return true; + } + return false; +} + +// ----------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------- + +bool ResolveSymbols(HANDLE hProcess, uintptr_t moduleBase, ResolvedSymbols &out) +{ + memset(&out, 0, sizeof(out)); + + // Initialize DbgHelp for this process + SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS | SYMOPT_LOAD_LINES | SYMOPT_DEBUG); + + if (!SymInitialize(hProcess, nullptr, FALSE)) { + LogWrite("SymInitialize failed: 0x%08x", GetLastError()); + return false; + } + + // Load symbols for the main module + char exePath[MAX_PATH]; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + + DWORD64 base = SymLoadModuleEx(hProcess, nullptr, exePath, nullptr, + moduleBase, 0, nullptr, 0); + if (!base) { + DWORD err = GetLastError(); + if (err != ERROR_SUCCESS) { + LogWrite("SymLoadModuleEx failed: 0x%08x", err); + SymCleanup(hProcess); + return false; + } + } + LogWrite("Symbols loaded for %s", exePath); + + // Resolve MinecraftServer::tick() + if (!TryResolve(hProcess, TICK_DECORATED, out.MinecraftServer_tick)) { + LogWrite("Decorated tick symbol not found, trying undecorated..."); + if (!TryResolve(hProcess, TICK_UNDECORATED, out.MinecraftServer_tick)) { + LogWrite("ERROR: Could not resolve MinecraftServer::tick()"); + SymCleanup(hProcess); + return false; + } + } + + // Resolve MinecraftServer::server (static pointer) + // getInstance() is likely inlined, so we grab the static member address + if (!TryResolve(hProcess, SERVER_PTR_DECORATED, out.MinecraftServer_getInstance)) { + LogWrite("Decorated server ptr not found, trying undecorated..."); + if (!TryResolve(hProcess, SERVER_PTR_UNDECORATED, out.MinecraftServer_getInstance)) { + LogWrite("WARNING: Could not resolve MinecraftServer::server -- metrics will be limited"); + // Not fatal -- we can still time the tick, just can't read members + } + } + + // Resolve ServerLevel::getChunkMap() + // ?getChunkMap@ServerLevel@@QEAAPEAVPlayerChunkMap@@XZ + if (!TryResolve(hProcess, "?getChunkMap@ServerLevel@@QEAAPEAVPlayerChunkMap@@XZ", + out.ServerLevel_getChunkMap)) { + if (!TryResolve(hProcess, "ServerLevel::getChunkMap", out.ServerLevel_getChunkMap)) { + LogWrite("WARNING: Could not resolve ServerLevel::getChunkMap()"); + } + } + + return true; +} diff --git a/tools/performance-monitor/dll/src/tcp_server.cpp b/tools/performance-monitor/dll/src/tcp_server.cpp new file mode 100644 index 00000000..7e72832e --- /dev/null +++ b/tools/performance-monitor/dll/src/tcp_server.cpp @@ -0,0 +1,297 @@ +// +// tcp_server.cpp -- Lightweight TCP server for streaming JSON metrics. +// +// Protocol: +// - Length-prefixed frames: [4 bytes big-endian uint32 length][UTF-8 JSON] +// - Client sends a "hello" message after connecting +// - Server responds with "hello_ack" then streams tick snapshots +// +// Threading: +// - Listener thread: accepts connections, reads client messages +// - Game thread (via hooks.cpp): pushes snapshots to the queue +// - Broadcast thread: drains queue, serializes, sends to all clients +// + +#include "perf_monitor.h" + +#include +#include + +#pragma comment(lib, "ws2_32.lib") + +// ----------------------------------------------------------------------- +// State +// ----------------------------------------------------------------------- + +static SOCKET g_listenSock = INVALID_SOCKET; +static HANDLE g_listenerThread = nullptr; +static HANDLE g_broadcastThread = nullptr; +static std::atomic g_running{false}; + +static std::mutex g_clientsMtx; +static std::vector g_clients; +static constexpr int MAX_CLIENTS = 4; + +static constexpr int SEND_BUF_LIMIT = 64 * 1024; // 64 KB per client + +// ----------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------- + +static void SendFrame(SOCKET sock, const std::string &json) +{ + uint32_t len = (uint32_t)json.size(); + // Big-endian length prefix + uint8_t header[4]; + header[0] = (len >> 24) & 0xFF; + header[1] = (len >> 16) & 0xFF; + header[2] = (len >> 8) & 0xFF; + header[3] = (len ) & 0xFF; + + // Send header + payload (non-blocking, best effort) + send(sock, reinterpret_cast(header), 4, 0); + send(sock, json.c_str(), (int)json.size(), 0); +} + +static void RemoveClient(SOCKET sock) +{ + closesocket(sock); + std::lock_guard lock(g_clientsMtx); + g_clients.erase( + std::remove(g_clients.begin(), g_clients.end(), sock), + g_clients.end()); + + if (g_clients.empty()) { + g_clientConnected.store(false, std::memory_order_release); + LogWrite("Last client disconnected, metrics collection paused"); + } +} + +// ----------------------------------------------------------------------- +// Listener thread -- accepts new connections +// ----------------------------------------------------------------------- + +static DWORD WINAPI ListenerThread(LPVOID) +{ + LogWrite("Listener thread started"); + + while (g_running.load()) { + fd_set readSet; + FD_ZERO(&readSet); + FD_SET(g_listenSock, &readSet); + + timeval timeout; + timeout.tv_sec = 1; + timeout.tv_usec = 0; + + int result = select(0, &readSet, nullptr, nullptr, &timeout); + if (result <= 0) continue; + + if (FD_ISSET(g_listenSock, &readSet)) { + sockaddr_in clientAddr; + int addrLen = sizeof(clientAddr); + SOCKET clientSock = accept(g_listenSock, + reinterpret_cast(&clientAddr), + &addrLen); + if (clientSock == INVALID_SOCKET) continue; + + // Check client limit + { + std::lock_guard lock(g_clientsMtx); + if ((int)g_clients.size() >= MAX_CLIENTS) { + LogWrite("Client rejected: max connections reached"); + closesocket(clientSock); + continue; + } + } + + // Set non-blocking + u_long nonBlock = 1; + ioctlsocket(clientSock, FIONBIO, &nonBlock); + + // Set TCP_NODELAY for low latency + int nodelay = 1; + setsockopt(clientSock, IPPROTO_TCP, TCP_NODELAY, + reinterpret_cast(&nodelay), sizeof(nodelay)); + + char addrStr[64]; + inet_ntop(AF_INET, &clientAddr.sin_addr, addrStr, sizeof(addrStr)); + LogWrite("Client connected from %s:%d", addrStr, ntohs(clientAddr.sin_port)); + + // Send hello_ack + std::string ack = SerializeHelloAck(3); // 3 dimensions + SendFrame(clientSock, ack); + + // Add to client list + { + std::lock_guard lock(g_clientsMtx); + g_clients.push_back(clientSock); + g_clientConnected.store(true, std::memory_order_release); + } + + LogWrite("Client added, metrics collection active"); + } + } + + LogWrite("Listener thread exiting"); + return 0; +} + +// ----------------------------------------------------------------------- +// Broadcast thread -- drains snapshot queue and sends to all clients +// ----------------------------------------------------------------------- + +static DWORD WINAPI BroadcastThread(LPVOID) +{ + LogWrite("Broadcast thread started"); + + while (g_running.load()) { + // Sleep briefly to batch snapshots (50ms = 1 tick at 20 TPS) + Sleep(50); + + if (!g_clientConnected.load(std::memory_order_relaxed)) continue; + + // Drain tick snapshots + auto ticks = DrainTickSnapshots(); + auto autosaves = DrainAutosaveSnapshots(); + + if (ticks.empty() && autosaves.empty()) continue; + + // Serialize all snapshots + std::vector frames; + frames.reserve(ticks.size() + autosaves.size()); + + for (auto &snap : ticks) { + frames.push_back(SerializeTick(snap)); + } + for (auto &snap : autosaves) { + frames.push_back(SerializeAutosave(snap)); + } + + // Send to all clients + std::vector deadClients; + + { + std::lock_guard lock(g_clientsMtx); + for (SOCKET sock : g_clients) { + bool failed = false; + for (auto &json : frames) { + uint32_t len = (uint32_t)json.size(); + uint8_t header[4]; + header[0] = (len >> 24) & 0xFF; + header[1] = (len >> 16) & 0xFF; + header[2] = (len >> 8) & 0xFF; + header[3] = (len ) & 0xFF; + + int r1 = send(sock, reinterpret_cast(header), 4, 0); + int r2 = send(sock, json.c_str(), (int)json.size(), 0); + + if (r1 == SOCKET_ERROR || r2 == SOCKET_ERROR) { + int err = WSAGetLastError(); + if (err != WSAEWOULDBLOCK) { + failed = true; + break; + } + // WOULDBLOCK: client can't keep up, skip this frame + break; + } + } + if (failed) { + deadClients.push_back(sock); + } + } + } + + // Clean up dead clients + for (SOCKET sock : deadClients) { + LogWrite("Client disconnected (send error)"); + RemoveClient(sock); + } + } + + LogWrite("Broadcast thread exiting"); + return 0; +} + +// ----------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------- + +bool TcpServerStart(int port) +{ + WSADATA wsaData; + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { + // WSA may already be initialized by the server -- that's fine + } + + g_listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (g_listenSock == INVALID_SOCKET) { + LogWrite("socket() failed: %d", WSAGetLastError()); + return false; + } + + // Allow address reuse + int reuse = 1; + setsockopt(g_listenSock, SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&reuse), sizeof(reuse)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; // Listen on all interfaces + addr.sin_port = htons(static_cast(port)); + + if (bind(g_listenSock, reinterpret_cast(&addr), sizeof(addr)) == SOCKET_ERROR) { + LogWrite("bind() failed on port %d: %d", port, WSAGetLastError()); + closesocket(g_listenSock); + g_listenSock = INVALID_SOCKET; + return false; + } + + if (listen(g_listenSock, 4) == SOCKET_ERROR) { + LogWrite("listen() failed: %d", WSAGetLastError()); + closesocket(g_listenSock); + g_listenSock = INVALID_SOCKET; + return false; + } + + g_running.store(true); + + g_listenerThread = CreateThread(nullptr, 0, ListenerThread, nullptr, 0, nullptr); + g_broadcastThread = CreateThread(nullptr, 0, BroadcastThread, nullptr, 0, nullptr); + + return true; +} + +void TcpServerStop() +{ + g_running.store(false); + + if (g_listenSock != INVALID_SOCKET) { + closesocket(g_listenSock); + g_listenSock = INVALID_SOCKET; + } + + // Close all client sockets + { + std::lock_guard lock(g_clientsMtx); + for (SOCKET s : g_clients) { + closesocket(s); + } + g_clients.clear(); + } + g_clientConnected.store(false); + + // Wait for threads + if (g_listenerThread) { + WaitForSingleObject(g_listenerThread, 3000); + CloseHandle(g_listenerThread); + g_listenerThread = nullptr; + } + if (g_broadcastThread) { + WaitForSingleObject(g_broadcastThread, 3000); + CloseHandle(g_broadcastThread); + g_broadcastThread = nullptr; + } + + LogWrite("TCP server stopped"); +} diff --git a/tools/performance-monitor/gui.py b/tools/performance-monitor/gui.py new file mode 100644 index 00000000..e53668a8 --- /dev/null +++ b/tools/performance-monitor/gui.py @@ -0,0 +1,557 @@ +""" +PySide6 GUI for the server-side performance monitor. + +Connects to the injected DLL's TCP server and visualizes tick-level +performance data: phase breakdown, TPS graph, entity/chunk counts, +memory usage, and lag spike root causes. +""" + +import logging +import time +from collections import deque + +from PySide6.QtCore import Qt, QTimer, Slot +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QGroupBox, + QSpinBox, QSplitter, QTextEdit, QScrollArea, +) + +from connection import PerfConnection +from bots import BotManager +from models import TickSnapshot, AutosaveSnapshot +from widgets import ( + TickBreakdownBar, TPSGraph, StatCard, + EntityChunkPanel, MemoryBar, LagSpikePanel, +) + + +DIMENSION_NAMES = {0: "Overworld", -1: "Nether", 1: "End"} + + +def compute_deltas(prev: TickSnapshot | None, curr: TickSnapshot) -> list[str]: + """Compare two consecutive snapshots and describe what changed.""" + if prev is None: + return [] + + events: list[str] = [] + + prev_levels = {lv.dimension: lv for lv in prev.levels} + curr_levels = {lv.dimension: lv for lv in curr.levels} + + for dim in (0, -1, 1): + p = prev_levels.get(dim) + c = curr_levels.get(dim) + if not p or not c: + continue + + name = DIMENSION_NAMES.get(dim, f"Dim{dim}") + + # Player join/leave + pdiff = c.player_count - p.player_count + if pdiff > 0: + events.append(f"+{pdiff} player{'s' if pdiff > 1 else ''} joined ({name})") + elif pdiff < 0: + events.append(f"{pdiff} player{'s' if pdiff < -1 else ''} left ({name})") + + # Entity spawn/despawn (threshold to avoid noise from normal fluctuation) + ediff = c.entity_count - p.entity_count + if ediff >= 10: + events.append(f"+{ediff} entities spawned ({name}, now {c.entity_count})") + elif ediff <= -10: + events.append(f"{ediff} entities despawned ({name}, now {c.entity_count})") + + # Tile entity changes (redstone, hoppers, etc.) + tdiff = c.tile_entity_count - p.tile_entity_count + if tdiff >= 5: + events.append(f"+{tdiff} tile entities ({name})") + elif tdiff <= -5: + events.append(f"{tdiff} tile entities ({name})") + + # Entities pending removal spike + if c.entities_to_remove > 10 and c.entities_to_remove > p.entities_to_remove + 5: + events.append(f"{c.entities_to_remove} entities queued for removal ({name})") + + # Chunk load/unload + if c.loaded_chunks > 0 and p.loaded_chunks > 0: + cdiff = c.loaded_chunks - p.loaded_chunks + if cdiff >= 5: + events.append(f"+{cdiff} chunks loaded ({name}, now {c.loaded_chunks})") + elif cdiff <= -5: + events.append(f"{cdiff} chunks unloaded ({name}, now {c.loaded_chunks})") + + return events + + +def classify_spike(interval_ms: float, work_ms: float, + snap: TickSnapshot, prev: TickSnapshot | None, + prev_timestamps: list[float]) -> str: + """Classify a lag spike by its most likely cause, with context from deltas.""" + + # Compute what changed since last tick + deltas = compute_deltas(prev, snap) + delta_str = "; ".join(deltas) if deltas else "" + + # Check for player join/leave (takes priority over autosave classification) + player_joined = any("joined" in d for d in deltas) + player_left = any("left" in d for d in deltas) + chunk_change = any("chunk" in d for d in deltas) + + # --- Player join stall --- + # When a player joins, the server loads their chunks, sends world data, + # and initializes the player entity. This blocks the run loop. + if player_joined and interval_ms > 200: + reason = f"Player join (server stalled {interval_ms:.0f}ms loading player data)" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Player leave --- + if player_left and interval_ms > 200: + reason = f"Player leave (cleanup stalled {interval_ms:.0f}ms)" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Autosave detection --- + # Large stall (>500ms) with low tick work and NO player join/leave. + # Autosave's synchronous Flush() compresses + writes the save file, + # blocking the entire run() loop. Typically recurs at a regular interval. + if interval_ms > 500 and work_ms < 50: + pattern = "" + if len(prev_timestamps) >= 1: + gap = time.time() - prev_timestamps[-1] + pattern = f", ~{gap:.0f}s since last spike" + reason = f"Autosave (sync flush for {interval_ms:.0f}ms{pattern})" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Heavy tick (entity/redstone overload) --- + if work_ms > 50: + reason = f"Heavy tick ({work_ms:.1f}ms work, {snap.total_entity_count} entities)" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Moderate tick slowdown --- + if work_ms > 25: + reason = f"Slow tick ({work_ms:.1f}ms work, {snap.total_entity_count} entities)" + if delta_str: + reason += f" | {delta_str}" + return reason + + # --- Short stall with context --- + if delta_str: + return f"Stall ({interval_ms:.0f}ms gap, tick {work_ms:.1f}ms) | {delta_str}" + + # --- Minor external stall (no obvious context) --- + if interval_ms < 200: + return f"Minor stall ({interval_ms:.0f}ms gap, tick only {work_ms:.1f}ms)" + + # --- Medium external stall --- + return f"External stall ({interval_ms:.0f}ms gap, tick only {work_ms:.1f}ms)" + + +class PerfMonitorWindow(QMainWindow): + + def __init__(self): + super().__init__() + self.setWindowTitle("LCE Server Performance Monitor") + self.setMinimumSize(1000, 700) + + self._connection = PerfConnection(self) + self._bot_manager = BotManager(self) + self._flog = logging.getLogger("perfmon") + + # History for timeline + self._history: deque[TickSnapshot] = deque(maxlen=6000) # 5 min at 20 TPS + + # Wire signals + self._connection.connected.connect(self._on_connected) + self._connection.disconnected.connect(self._on_disconnected) + self._connection.tick_received.connect(self._on_tick) + self._connection.autosave_received.connect(self._on_autosave) + self._connection.error.connect(self._on_error) + self._connection.log.connect(self._append_log) + + self._bot_manager.log.connect(self._append_log) + self._bot_manager.bot_added.connect(self._on_bot_added) + self._bot_manager.bot_removed.connect(self._on_bot_removed) + + self._build_ui() + + # Graph refresh timer + self._refresh = QTimer(self) + self._refresh.timeout.connect(self._refresh_graph) + self._refresh.start(500) + + # Periodic TPS log (every 5 seconds) + self._tps_log_timer = QTimer(self) + self._tps_log_timer.timeout.connect(self._log_tps) + self._tps_log_timer.start(5000) + self._last_logged_tps = 0.0 + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(8) + + # --- Connection bar --- + conn_box = QGroupBox("Connection") + conn_box.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + conn_layout = QHBoxLayout(conn_box) + + conn_layout.addWidget(QLabel("Host:")) + self._host_input = QLineEdit("127.0.0.1") + self._host_input.setFixedWidth(140) + conn_layout.addWidget(self._host_input) + + conn_layout.addWidget(QLabel("Port:")) + self._port_input = QSpinBox() + self._port_input.setRange(1, 65535) + self._port_input.setValue(19800) + self._port_input.setFixedWidth(80) + conn_layout.addWidget(self._port_input) + + self._connect_btn = QPushButton("Connect") + self._connect_btn.setFixedWidth(100) + self._connect_btn.clicked.connect(self._toggle_connection) + conn_layout.addWidget(self._connect_btn) + + self._status_label = QLabel("Disconnected") + self._status_label.setStyleSheet("color: #ff5555; font-weight: bold;") + conn_layout.addWidget(self._status_label) + conn_layout.addStretch() + root.addWidget(conn_box) + + # --- Bot controls --- + bot_box = QGroupBox("Load Testing") + bot_box.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + bot_layout = QHBoxLayout(bot_box) + + bot_layout.addWidget(QLabel("Game Port:")) + self._game_port_input = QSpinBox() + self._game_port_input.setRange(1, 65535) + self._game_port_input.setValue(25565) + self._game_port_input.setFixedWidth(80) + bot_layout.addWidget(self._game_port_input) + + self._add_bot_btn = QPushButton("Add Bot") + self._add_bot_btn.setFixedWidth(80) + self._add_bot_btn.clicked.connect(self._add_bot) + bot_layout.addWidget(self._add_bot_btn) + + self._remove_bot_btn = QPushButton("Remove Bot") + self._remove_bot_btn.setFixedWidth(100) + self._remove_bot_btn.clicked.connect(self._remove_bot) + bot_layout.addWidget(self._remove_bot_btn) + + self._remove_all_bots_btn = QPushButton("Remove All") + self._remove_all_bots_btn.setFixedWidth(90) + self._remove_all_bots_btn.clicked.connect(self._remove_all_bots) + bot_layout.addWidget(self._remove_all_bots_btn) + + self._bot_count_label = QLabel("Bots: 0") + self._bot_count_label.setStyleSheet("color: #aaaacc; font-weight: bold;") + bot_layout.addWidget(self._bot_count_label) + + bot_layout.addStretch() + root.addWidget(bot_box) + + # --- Stat cards --- + stats_layout = QHBoxLayout() + self._tps_card = StatCard("TPS (est.)") + self._mspt_card = StatCard("Tick Time") + self._entities_card = StatCard("Entities") + self._players_card = StatCard("Players") + self._memory_card = StatCard("Memory") + self._spikes_card = StatCard("Lag Spikes") + + for card in [ + self._tps_card, self._mspt_card, self._entities_card, + self._players_card, self._memory_card, self._spikes_card, + ]: + stats_layout.addWidget(card) + root.addLayout(stats_layout) + + # --- Tick breakdown bar --- + self._breakdown = TickBreakdownBar() + root.addWidget(self._breakdown) + + # --- Main content splitter --- + hsplit = QSplitter(Qt.Horizontal) + + # Left: graph + memory + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(0, 0, 0, 0) + + self._graph = TPSGraph() + left_layout.addWidget(self._graph) + + self._memory_bar = MemoryBar() + left_layout.addWidget(self._memory_bar) + + hsplit.addWidget(left) + + # Right: entity panel + spike panel + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + + self._entity_panel = EntityChunkPanel() + right_layout.addWidget(self._entity_panel) + + self._spike_panel = LagSpikePanel() + spike_scroll = QScrollArea() + spike_scroll.setWidget(self._spike_panel) + spike_scroll.setWidgetResizable(True) + spike_scroll.setStyleSheet("QScrollArea { border: none; background: transparent; }") + right_layout.addWidget(spike_scroll) + + hsplit.addWidget(right) + hsplit.setStretchFactor(0, 3) + hsplit.setStretchFactor(1, 2) + root.addWidget(hsplit) + + # --- Log pane --- + log_box = QGroupBox("Log") + log_box.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + log_layout = QVBoxLayout(log_box) + self._log = QTextEdit() + self._log.setReadOnly(True) + self._log.setFont(QFont("Consolas", 9)) + self._log.setStyleSheet("background: #12121a; color: #ccccdd; border: none;") + self._log.setMaximumHeight(150) + log_layout.addWidget(self._log) + root.addWidget(log_box) + + self._apply_theme() + + def _apply_theme(self): + self.setStyleSheet(""" + QMainWindow { background: #0e0e16; } + QWidget { color: #ccccdd; font-family: 'Segoe UI', sans-serif; } + QLabel { color: #aaaacc; } + QLineEdit, QSpinBox { + background: #1a1a24; color: #ffffff; border: 1px solid #333348; + border-radius: 4px; padding: 4px 8px; + } + QPushButton { + background: #2a2a3a; color: #ffffff; border: 1px solid #444466; + border-radius: 4px; padding: 6px 16px; + } + QPushButton:hover { background: #3a3a4a; } + QPushButton:pressed { background: #1a1a2a; } + QGroupBox { background: #14141e; } + """) + + # --- Spike counter --- + _spike_count = 0 + _spike_timestamps: list[float] = [] + _prev_snap: TickSnapshot | None = None + + # TPS tracking + _tps_samples: list[float] = [] + + # --- Slots --- + + def _toggle_connection(self): + if self._connection.is_connected: + self._connection.disconnect() + else: + host = self._host_input.text().strip() + port = self._port_input.value() + self._history.clear() + self._spike_count = 0 + self._spike_timestamps = [] + self._prev_snap = None + self._tps_samples = [] + self._connection.connect_to(host, port) + + @Slot(dict) + def _on_connected(self, hello: dict): + self._status_label.setText("Connected") + self._status_label.setStyleSheet("color: #50c878; font-weight: bold;") + self._connect_btn.setText("Disconnect") + + @Slot(str) + def _on_disconnected(self, reason: str): + self._status_label.setText(f"Disconnected: {reason}") + self._status_label.setStyleSheet("color: #ff5555; font-weight: bold;") + self._connect_btn.setText("Connect") + + @Slot(object) + def _on_tick(self, snap: TickSnapshot): + self._history.append(snap) + now = time.time() + + # TPS from tick-to-tick interval (total_us). + # This measures the real time between consecutive tick() calls, + # including any stalls from autosave or other work in run(). + interval_us = snap.total_us + if interval_us > 0: + instant_tps = min(1_000_000.0 / interval_us, 20.0) + else: + instant_tps = 20.0 + + self._tps_samples.append(instant_tps) + if len(self._tps_samples) > 20: + self._tps_samples = self._tps_samples[-20:] + tps = sum(self._tps_samples) / len(self._tps_samples) + + if tps >= 19.0: + tps_color = "#50c878" + elif tps >= 15.0: + tps_color = "#ffc83d" + else: + tps_color = "#ff5050" + self._tps_card.set_value(f"{tps:.1f}", tps_color) + + # MSPT = actual tick() work time (carried in pool_reset_us) + mspt = snap.pool_reset_us / 1000.0 + if mspt <= 50: + mspt_color = "#50c878" + elif mspt <= 65: + mspt_color = "#ffc83d" + else: + mspt_color = "#ff5050" + self._mspt_card.set_value(f"{mspt:.1f}ms", mspt_color) + + total_ent = snap.total_entity_count + self._entities_card.set_value(str(total_ent)) + total_players = sum(lv.player_count for lv in snap.levels) + self._players_card.set_value(str(total_players)) + + self._memory_card.set_value(f"{snap.memory_used_mb:.0f}MB") + + # Update breakdown bar + self._breakdown.update_from_snapshot(snap) + + # Update entity/chunk panel + self._entity_panel.update_from_snapshot(snap) + + # Update memory bar + self._memory_bar.update_memory(snap.memory_used_mb, snap.memory_total_mb) + + # TPS graph data point + self._graph.add_point(now, tps) + + # Check for lag spikes: tick interval exceeded 150ms (3x the 50ms budget). + # This filters out steady-state slowness (e.g. 15 TPS = 66ms intervals) + # and only reports true spikes -- autosave stalls, player joins, etc. + if snap.total_us > 150000: + self._spike_count += 1 + interval_ms = snap.total_us / 1000.0 + work_ms = snap.pool_reset_us / 1000.0 + + reason = classify_spike( + interval_ms, work_ms, snap, self._prev_snap, + self._spike_timestamps) + self._spike_timestamps.append(now) + + self._spike_panel.add_spike_with_reason(snap, reason) + self._append_log( + f"LAG SPIKE: {interval_ms:.0f}ms interval, " + f"tick work={work_ms:.1f}ms, " + f"entities={snap.total_entity_count}, " + f"TPS={tps:.1f}, " + f"cause={reason}" + ) + + self._spikes_card.set_value( + str(self._spike_count), + "#ff5050" if self._spike_count > 0 else "#50c878", + ) + + # Log notable events even without a spike + if self._prev_snap is not None and snap.total_us <= 75000: + deltas = compute_deltas(self._prev_snap, snap) + for d in deltas: + if "joined" in d or "left" in d: + self._append_log(f"EVENT: {d}") + + self._prev_snap = snap + + @Slot(object) + def _on_autosave(self, snap: AutosaveSnapshot): + self._spike_panel.add_autosave(snap) + self._append_log( + f"AUTOSAVE: {snap.total_ms:.0f}ms total " + f"(players={snap.players_us / 1000:.0f}ms, " + f"levels={snap.levels_us / 1000:.0f}ms, " + f"rules={snap.rules_us / 1000:.0f}ms, " + f"flush={snap.flush_us / 1000:.0f}ms)" + ) + + @Slot(str) + def _on_error(self, msg: str): + self._append_log(f"ERROR: {msg}") + + # --- Bot controls --- + + def _add_bot(self): + host = self._host_input.text().strip() + port = self._game_port_input.value() + self._bot_manager.add_bot(host, port) + + def _remove_bot(self): + self._bot_manager.remove_bot() + + def _remove_all_bots(self): + self._bot_manager.remove_all() + + @Slot(str) + def _on_bot_added(self, name: str): + self._bot_count_label.setText(f"Bots: {self._bot_manager.bot_count}") + + @Slot(str) + def _on_bot_removed(self, name: str): + self._bot_count_label.setText(f"Bots: {self._bot_manager.bot_count}") + + def _append_log(self, msg: str): + self._flog.info(msg) + ts = time.strftime("%H:%M:%S") + self._log.append(f"[{ts}] {msg}") + + def _refresh_graph(self): + self._graph.update() + + def _log_tps(self): + if not self._connection.is_connected or not self._tps_samples: + return + tps = sum(self._tps_samples) / len(self._tps_samples) + snap = self._prev_snap + if snap: + work_ms = snap.pool_reset_us / 1000.0 + ent = snap.total_entity_count + players = sum(lv.player_count for lv in snap.levels) + chunks = sum(lv.loaded_chunks for lv in snap.levels if lv.loaded_chunks > 0) + self._append_log( + f"TPS: {tps:.1f} | MSPT: {work_ms:.1f}ms | " + f"Players: {players} | Entities: {ent} | Chunks: {chunks} | " + f"Memory: {snap.memory_used_mb:.0f}MB" + ) + else: + self._append_log(f"TPS: {tps:.1f}") + + def closeEvent(self, event): + self._bot_manager.remove_all() + self._connection.disconnect() + super().closeEvent(event) diff --git a/tools/performance-monitor/inject.bat b/tools/performance-monitor/inject.bat new file mode 100644 index 00000000..d742a1a4 --- /dev/null +++ b/tools/performance-monitor/inject.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +python inject.py %* +pause diff --git a/tools/performance-monitor/inject.py b/tools/performance-monitor/inject.py new file mode 100644 index 00000000..53139437 --- /dev/null +++ b/tools/performance-monitor/inject.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +DLL Injector for perf-monitor.dll. + +Finds a running Minecraft.Server.exe process and injects the performance +monitoring DLL using the standard CreateRemoteThread + LoadLibraryA technique. + +Usage: + python inject.py # Auto-find server process + python inject.py --pid 12345 # Inject into specific PID + python inject.py --dll path/to.dll # Use a specific DLL path + python inject.py --eject # Unload the DLL from the server +""" + +import argparse +import ctypes +import ctypes.wintypes as wt +import os +import sys + +# Win32 constants +PROCESS_ALL_ACCESS = 0x1F0FFF +MEM_COMMIT = 0x1000 +MEM_RESERVE = 0x2000 +MEM_RELEASE = 0x8000 +PAGE_READWRITE = 0x04 +INFINITE = 0xFFFFFFFF + +# Win32 API +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +psapi = ctypes.WinDLL("psapi", use_last_error=True) + +# Type aliases +DWORD = wt.DWORD +HANDLE = wt.HANDLE +LPVOID = wt.LPVOID +LPCSTR = wt.LPCSTR +BOOL = wt.BOOL +SIZE_T = ctypes.c_size_t + +# Function prototypes +kernel32.OpenProcess.restype = HANDLE +kernel32.OpenProcess.argtypes = [DWORD, BOOL, DWORD] + +kernel32.VirtualAllocEx.restype = LPVOID +kernel32.VirtualAllocEx.argtypes = [HANDLE, LPVOID, SIZE_T, DWORD, DWORD] + +kernel32.VirtualFreeEx.restype = BOOL +kernel32.VirtualFreeEx.argtypes = [HANDLE, LPVOID, SIZE_T, DWORD] + +kernel32.WriteProcessMemory.restype = BOOL +kernel32.WriteProcessMemory.argtypes = [HANDLE, LPVOID, ctypes.c_void_p, SIZE_T, ctypes.POINTER(SIZE_T)] + +kernel32.CreateRemoteThread.restype = HANDLE +kernel32.CreateRemoteThread.argtypes = [HANDLE, ctypes.c_void_p, SIZE_T, LPVOID, LPVOID, DWORD, ctypes.POINTER(DWORD)] + +kernel32.WaitForSingleObject.restype = DWORD +kernel32.WaitForSingleObject.argtypes = [HANDLE, DWORD] + +kernel32.GetExitCodeThread.restype = BOOL +kernel32.GetExitCodeThread.argtypes = [HANDLE, ctypes.POINTER(DWORD)] + +kernel32.CloseHandle.restype = BOOL +kernel32.CloseHandle.argtypes = [HANDLE] + +kernel32.GetModuleHandleA.restype = HANDLE +kernel32.GetModuleHandleA.argtypes = [LPCSTR] + +kernel32.GetProcAddress.restype = ctypes.c_void_p +kernel32.GetProcAddress.argtypes = [HANDLE, LPCSTR] + + +def find_server_pid() -> int | None: + """Find the PID of a running Minecraft.Server.exe.""" + # Use EnumProcesses to list all PIDs + arr = (DWORD * 4096)() + cb_needed = DWORD() + if not psapi.EnumProcesses(ctypes.byref(arr), ctypes.sizeof(arr), ctypes.byref(cb_needed)): + return None + + num_procs = cb_needed.value // ctypes.sizeof(DWORD) + + for i in range(num_procs): + pid = arr[i] + if pid == 0: + continue + + h = kernel32.OpenProcess(0x0410, False, pid) # PROCESS_QUERY_INFORMATION | PROCESS_VM_READ + if not h: + continue + + try: + name_buf = (ctypes.c_char * 260)() + if psapi.GetModuleBaseNameA(h, None, name_buf, 260): + name = name_buf.value.decode("utf-8", errors="ignore") + if name.lower() == "minecraft.server.exe": + return pid + finally: + kernel32.CloseHandle(h) + + return None + + +def inject_dll(pid: int, dll_path: str) -> bool: + """Inject a DLL into the target process.""" + dll_path = os.path.abspath(dll_path) + if not os.path.isfile(dll_path): + print(f"ERROR: DLL not found: {dll_path}") + return False + + dll_bytes = dll_path.encode("utf-8") + b"\x00" + print(f"Injecting {dll_path} into PID {pid}...") + + # Open the target process + h_process = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid) + if not h_process: + print(f"ERROR: OpenProcess failed (error {ctypes.get_last_error()})") + print(" Are you running as Administrator?") + return False + + try: + # Allocate memory in the target for the DLL path string + remote_mem = kernel32.VirtualAllocEx( + h_process, None, len(dll_bytes), + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE + ) + if not remote_mem: + print(f"ERROR: VirtualAllocEx failed (error {ctypes.get_last_error()})") + return False + + # Write the DLL path into the allocated memory + written = SIZE_T(0) + if not kernel32.WriteProcessMemory( + h_process, remote_mem, dll_bytes, len(dll_bytes), ctypes.byref(written) + ): + print(f"ERROR: WriteProcessMemory failed (error {ctypes.get_last_error()})") + kernel32.VirtualFreeEx(h_process, remote_mem, 0, MEM_RELEASE) + return False + + # Get the address of LoadLibraryA in kernel32 + h_kernel32 = kernel32.GetModuleHandleA(b"kernel32.dll") + load_library_addr = kernel32.GetProcAddress(h_kernel32, b"LoadLibraryA") + if not load_library_addr: + print("ERROR: Could not find LoadLibraryA") + kernel32.VirtualFreeEx(h_process, remote_mem, 0, MEM_RELEASE) + return False + + # Create a remote thread that calls LoadLibraryA(dll_path) + thread_id = DWORD(0) + h_thread = kernel32.CreateRemoteThread( + h_process, None, 0, + ctypes.cast(load_library_addr, LPVOID), + remote_mem, 0, ctypes.byref(thread_id) + ) + if not h_thread: + print(f"ERROR: CreateRemoteThread failed (error {ctypes.get_last_error()})") + kernel32.VirtualFreeEx(h_process, remote_mem, 0, MEM_RELEASE) + return False + + print(f"Remote thread created (TID {thread_id.value}), waiting...") + + # Wait for LoadLibrary to complete + kernel32.WaitForSingleObject(h_thread, 10000) # 10 second timeout + + # Check the exit code (HMODULE of the loaded DLL, or 0 on failure) + exit_code = DWORD(0) + kernel32.GetExitCodeThread(h_thread, ctypes.byref(exit_code)) + kernel32.CloseHandle(h_thread) + + # Free the remote string memory + kernel32.VirtualFreeEx(h_process, remote_mem, 0, MEM_RELEASE) + + if exit_code.value == 0: + print("ERROR: LoadLibraryA returned NULL in the target process.") + print(" Check that the DLL and its dependencies (dbghelp.dll) are accessible.") + print(" Check perf-monitor.log next to the DLL for details.") + return False + + print(f"DLL loaded at 0x{exit_code.value:X}") + print("Injection successful! The performance monitor is now active.") + print("Connect the GUI to localhost:19800 to view metrics.") + return True + + finally: + kernel32.CloseHandle(h_process) + + +def eject_dll(pid: int, dll_name: str = "perf-monitor.dll") -> bool: + """Unload the DLL from the target process.""" + print(f"Ejecting {dll_name} from PID {pid}...") + + h_process = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid) + if not h_process: + print(f"ERROR: OpenProcess failed (error {ctypes.get_last_error()})") + return False + + try: + # Find the DLL's HMODULE in the target process + h_modules = (HANDLE * 1024)() + cb_needed = DWORD() + if not psapi.EnumProcessModules(h_process, ctypes.byref(h_modules), + ctypes.sizeof(h_modules), ctypes.byref(cb_needed)): + print("ERROR: EnumProcessModules failed") + return False + + num_modules = cb_needed.value // ctypes.sizeof(HANDLE) + target_module = None + + for i in range(num_modules): + name_buf = (ctypes.c_char * 260)() + if psapi.GetModuleBaseNameA(h_process, h_modules[i], name_buf, 260): + name = name_buf.value.decode("utf-8", errors="ignore") + if name.lower() == dll_name.lower(): + target_module = h_modules[i] + break + + if not target_module: + print(f"ERROR: {dll_name} not found in process") + return False + + # Get FreeLibrary address + h_kernel32 = kernel32.GetModuleHandleA(b"kernel32.dll") + free_library_addr = kernel32.GetProcAddress(h_kernel32, b"FreeLibrary") + + # Call FreeLibrary(hModule) in the target + thread_id = DWORD(0) + h_thread = kernel32.CreateRemoteThread( + h_process, None, 0, + ctypes.cast(free_library_addr, LPVOID), + target_module, 0, ctypes.byref(thread_id) + ) + if not h_thread: + print(f"ERROR: CreateRemoteThread failed (error {ctypes.get_last_error()})") + return False + + kernel32.WaitForSingleObject(h_thread, 10000) + kernel32.CloseHandle(h_thread) + + print("DLL ejected successfully.") + return True + + finally: + kernel32.CloseHandle(h_process) + + +def main(): + parser = argparse.ArgumentParser(description="Inject perf-monitor.dll into Minecraft.Server.exe") + parser.add_argument("--pid", type=int, default=None, help="Target process ID (auto-detect if omitted)") + parser.add_argument("--dll", default=None, help="Path to perf-monitor.dll") + parser.add_argument("--eject", action="store_true", help="Eject (unload) the DLL instead of injecting") + args = parser.parse_args() + + # Find or validate the PID + pid = args.pid + if pid is None: + pid = find_server_pid() + if pid is None: + print("ERROR: No running Minecraft.Server.exe found.") + print(" Start the server first, or specify --pid manually.") + sys.exit(1) + print(f"Found Minecraft.Server.exe (PID {pid})") + + if args.eject: + success = eject_dll(pid) + sys.exit(0 if success else 1) + + # Find the DLL + dll_path = args.dll + if dll_path is None: + # Look for it relative to this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + os.path.join(script_dir, "dll", "build", "Release", "perf-monitor.dll"), + os.path.join(script_dir, "dll", "build", "Debug", "perf-monitor.dll"), + os.path.join(script_dir, "dll", "build", "perf-monitor.dll"), + os.path.join(script_dir, "perf-monitor.dll"), + ] + for candidate in candidates: + if os.path.isfile(candidate): + dll_path = candidate + break + + if dll_path is None: + print("ERROR: Could not find perf-monitor.dll") + print(" Build it first or specify --dll path") + print(" Searched:", "\n ".join(candidates)) + sys.exit(1) + + success = inject_dll(pid, dll_path) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/performance-monitor/main.py b/tools/performance-monitor/main.py new file mode 100644 index 00000000..6cdb4e37 --- /dev/null +++ b/tools/performance-monitor/main.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +LCE Server Performance Monitor -- server-side instrumentation GUI. + +Connects to a performance monitoring DLL injected into the running +Minecraft.Server.exe process. Displays real-time tick phase breakdowns, +entity/chunk counts, memory usage, and lag spike root causes. + +Requires the DLL to be injected first: + python inject.py + +Usage: + python main.py + python main.py --host 127.0.0.1 --port 19800 +""" + +import sys +import os +import logging +import argparse + +from PySide6.QtWidgets import QApplication +from gui import PerfMonitorWindow + + +def setup_logging(): + """Configure file logger next to the executable/script.""" + if getattr(sys, "frozen", False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + + log_path = os.path.join(base, "perfmon.log") + + logger = logging.getLogger("perfmon") + logger.setLevel(logging.DEBUG) + + fh = logging.FileHandler(log_path, mode="w", encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S")) + + logger.addHandler(fh) + logger.info(f"Log file: {log_path}") + return logger + + +def main(): + parser = argparse.ArgumentParser(description="LCE Server Performance Monitor") + parser.add_argument("--host", default=None, help="Monitor host address") + parser.add_argument("--port", type=int, default=None, help="Monitor port") + args = parser.parse_args() + + setup_logging() + + app = QApplication(sys.argv) + app.setApplicationName("LCE Server Performance Monitor") + + window = PerfMonitorWindow() + + if args.host: + window._host_input.setText(args.host) + if args.port: + window._port_input.setValue(args.port) + + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/tools/performance-monitor/models.py b/tools/performance-monitor/models.py new file mode 100644 index 00000000..077d2d53 --- /dev/null +++ b/tools/performance-monitor/models.py @@ -0,0 +1,122 @@ +""" +Data models for performance monitor snapshots. + +All dataclasses are frozen (immutable) per project coding style. +Parsing functions validate at the boundary. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LevelSnapshot: + dimension: int + level_tick_us: int + entity_tick_us: int + entity_tick_skipped: bool + tracker_tick_us: int + entity_count: int + global_entity_count: int + player_count: int + loaded_chunks: int + entities_to_remove: int + tile_entity_count: int + + +@dataclass(frozen=True) +class TickSnapshot: + tick: int + timestamp_ms: int + total_us: int + + pool_reset_us: int + levels: tuple[LevelSnapshot, ...] + players_tick_us: int + connection_tick_us: int + console_tick_us: int + + total_players: int + memory_used_mb: float + memory_total_mb: float + is_autosaving: bool + is_paused: bool + + @property + def total_ms(self) -> float: + return self.total_us / 1000.0 + + @property + def tps(self) -> float: + """Estimated TPS: 20 if tick fits in budget, lower if it overruns.""" + if self.total_us <= 50000: + return 20.0 + # Tick took longer than 50ms -- server can't keep up + return min(1_000_000 / self.total_us, 20.0) + + @property + def total_entity_count(self) -> int: + return sum(lv.entity_count for lv in self.levels) + + +@dataclass(frozen=True) +class AutosaveSnapshot: + tick: int + timestamp_ms: int + total_us: int + players_us: int + levels_us: int + rules_us: int + flush_us: int + + @property + def total_ms(self) -> float: + return self.total_us / 1000.0 + + +def parse_level(data: dict) -> LevelSnapshot: + return LevelSnapshot( + dimension=data.get("dimension", 0), + level_tick_us=data.get("level_tick_us", 0), + entity_tick_us=data.get("entity_tick_us", 0), + entity_tick_skipped=data.get("entity_tick_skipped", False), + tracker_tick_us=data.get("tracker_tick_us", 0), + entity_count=data.get("entity_count", 0), + global_entity_count=data.get("global_entity_count", 0), + player_count=data.get("player_count", 0), + loaded_chunks=data.get("loaded_chunks", 0), + entities_to_remove=data.get("entities_to_remove", 0), + tile_entity_count=data.get("tile_entity_count", 0), + ) + + +def parse_tick(data: dict) -> TickSnapshot: + phases = data.get("phases", {}) + levels_raw = phases.get("levels", []) + return TickSnapshot( + tick=data.get("tick", 0), + timestamp_ms=data.get("timestamp_ms", 0), + total_us=data.get("total_us", 0), + pool_reset_us=phases.get("pool_reset_us", 0), + levels=tuple(parse_level(lv) for lv in levels_raw), + players_tick_us=phases.get("players_tick_us", 0), + connection_tick_us=phases.get("connection_tick_us", 0), + console_tick_us=phases.get("console_tick_us", 0), + total_players=data.get("server", {}).get("total_players", 0), + memory_used_mb=data.get("server", {}).get("memory_used_mb", 0.0), + memory_total_mb=data.get("server", {}).get("memory_total_mb", 0.0), + is_autosaving=data.get("server", {}).get("is_autosaving", False), + is_paused=data.get("server", {}).get("is_paused", False), + ) + + +def parse_autosave(data: dict) -> AutosaveSnapshot: + breakdown = data.get("breakdown", {}) + return AutosaveSnapshot( + tick=data.get("tick", 0), + timestamp_ms=data.get("timestamp_ms", 0), + total_us=data.get("total_us", 0), + players_us=breakdown.get("players_us", 0), + levels_us=breakdown.get("levels_us", 0), + rules_us=breakdown.get("rules_us", 0), + flush_us=breakdown.get("flush_us", 0), + ) diff --git a/tools/performance-monitor/requirements.txt b/tools/performance-monitor/requirements.txt new file mode 100644 index 00000000..1966c69e --- /dev/null +++ b/tools/performance-monitor/requirements.txt @@ -0,0 +1 @@ +PySide6>=6.6 diff --git a/tools/performance-monitor/start.bat b/tools/performance-monitor/start.bat new file mode 100644 index 00000000..796679cc --- /dev/null +++ b/tools/performance-monitor/start.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +python main.py %* diff --git a/tools/performance-monitor/widgets.py b/tools/performance-monitor/widgets.py new file mode 100644 index 00000000..49efcd2b --- /dev/null +++ b/tools/performance-monitor/widgets.py @@ -0,0 +1,494 @@ +""" +Custom widgets for the performance monitor GUI. + + - TickBreakdownBar: stacked horizontal bar showing per-phase timing + - TPSGraph: real-time TPS line chart (adapted from server-monitor) + - EntityChunkPanel: per-dimension entity/chunk counts + - MemoryBar: process memory usage bar + - LagSpikePanel: recent lag spikes with root cause +""" + +import time +from collections import deque + +from PySide6.QtCore import Qt +from PySide6.QtGui import QPainter, QColor, QPen, QFont, QBrush, QLinearGradient +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QGroupBox, QFrame, QScrollArea, QSizePolicy, +) + +from models import TickSnapshot, AutosaveSnapshot + +# ----------------------------------------------------------------------- +# Color palette for tick phases +# ----------------------------------------------------------------------- + +PHASE_COLORS = { + "pool_reset": QColor(100, 100, 120), + "level_tick_0": QColor(70, 130, 220), # Overworld level tick + "entity_tick_0": QColor(230, 120, 50), # Overworld entity tick + "tracker_tick_0": QColor(200, 200, 70), # Overworld tracker + "level_tick_1": QColor(150, 60, 180), # Nether level tick + "entity_tick_1": QColor(220, 60, 80), # Nether entity tick + "tracker_tick_1": QColor(180, 180, 50), # Nether tracker + "level_tick_2": QColor(50, 180, 170), # End level tick + "entity_tick_2": QColor(220, 100, 140), # End entity tick + "tracker_tick_2": QColor(160, 160, 50), # End tracker + "players_tick": QColor(80, 200, 120), + "connection_tick": QColor(80, 180, 220), + "console_tick": QColor(120, 120, 140), + "other": QColor(60, 60, 80), +} + +DIMENSION_NAMES = {0: "Overworld", -1: "Nether", 1: "End"} + + +# ----------------------------------------------------------------------- +# Tick Breakdown Bar +# ----------------------------------------------------------------------- + +class TickBreakdownBar(QWidget): + """Horizontal stacked bar showing which phase consumed time.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(40) + self.setMaximumHeight(50) + self._segments: list[tuple[str, int, QColor]] = [] # (label, us, color) + self._total_us: int = 0 + self._bg = QColor(24, 24, 32) + + def update_from_snapshot(self, snap: TickSnapshot): + segments = [] + + if snap.pool_reset_us > 0: + segments.append(("Pool Reset", snap.pool_reset_us, PHASE_COLORS["pool_reset"])) + + for i, lv in enumerate(snap.levels): + suffix = str(i) + dim_name = DIMENSION_NAMES.get(lv.dimension, f"Dim{lv.dimension}") + + if lv.level_tick_us > 0: + segments.append( + (f"{dim_name} Weather", lv.level_tick_us, + PHASE_COLORS.get(f"level_tick_{suffix}", PHASE_COLORS["other"]))) + + if not lv.entity_tick_skipped and lv.entity_tick_us > 0: + segments.append( + (f"{dim_name} Entities", lv.entity_tick_us, + PHASE_COLORS.get(f"entity_tick_{suffix}", PHASE_COLORS["other"]))) + + if lv.tracker_tick_us > 0: + segments.append( + (f"{dim_name} Tracker", lv.tracker_tick_us, + PHASE_COLORS.get(f"tracker_tick_{suffix}", PHASE_COLORS["other"]))) + + if snap.players_tick_us > 0: + segments.append(("Players", snap.players_tick_us, PHASE_COLORS["players_tick"])) + + if snap.connection_tick_us > 0: + segments.append(("Network", snap.connection_tick_us, PHASE_COLORS["connection_tick"])) + + if snap.console_tick_us > 0: + segments.append(("Console", snap.console_tick_us, PHASE_COLORS["console_tick"])) + + # "Other" time = total - sum of known phases + accounted = sum(s[1] for s in segments) + other = snap.total_us - accounted + if other > 0: + segments.append(("Other", other, PHASE_COLORS["other"])) + + self._segments = segments + self._total_us = snap.total_us + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + w, h = self.width(), self.height() + painter.fillRect(0, 0, w, h, self._bg) + + if self._total_us <= 0 or not self._segments: + painter.setPen(QPen(QColor(140, 140, 160), 1)) + painter.setFont(QFont("Consolas", 10)) + painter.drawText(0, 0, w, h, Qt.AlignCenter, "No data") + painter.end() + return + + # Draw the 50ms budget line + budget_x = min(w * (50000 / max(self._total_us, 50000)), w) + + # Draw segments + x = 0.0 + painter.setFont(QFont("Consolas", 8)) + for label, us, color in self._segments: + seg_w = (us / self._total_us) * w + if seg_w < 1: + continue + + painter.fillRect(int(x), 2, int(seg_w), h - 4, color) + + # Label if wide enough + if seg_w > 60: + painter.setPen(QPen(QColor(255, 255, 255), 1)) + text = f"{label} {us / 1000:.1f}ms" + painter.drawText(int(x) + 4, 2, int(seg_w) - 8, h - 4, + Qt.AlignVCenter | Qt.AlignLeft, text) + + x += seg_w + + # Budget line (50ms = 1 tick at 20 TPS) + if self._total_us > 50000: + bx = int(w * 50000 / self._total_us) + painter.setPen(QPen(QColor(255, 80, 80, 180), 2, Qt.DashLine)) + painter.drawLine(bx, 0, bx, h) + + # Total label on the right + painter.setPen(QPen(QColor(200, 200, 220), 1)) + painter.setFont(QFont("Consolas", 9)) + total_text = f"{self._total_us / 1000:.1f}ms" + painter.drawText(w - 80, 0, 75, h, Qt.AlignVCenter | Qt.AlignRight, total_text) + + painter.end() + + +# ----------------------------------------------------------------------- +# TPS Graph +# ----------------------------------------------------------------------- + +class TPSGraph(QWidget): + """Real-time TPS line chart.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(200) + self.setMinimumWidth(400) + self._data: deque[tuple[float, float]] = deque(maxlen=6000) # 5 min at 20 TPS + self._bg = QColor(24, 24, 32) + self._grid = QColor(48, 48, 64) + self._line_good = QColor(80, 200, 120) + self._line_warn = QColor(255, 200, 60) + self._line_bad = QColor(255, 80, 80) + + def add_point(self, timestamp: float, tps: float): + self._data.append((timestamp, tps)) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + w, h = self.width(), self.height() + ml, mb, mt, mr = 50, 30, 20, 20 + + painter.fillRect(0, 0, w, h, self._bg) + + gw = w - ml - mr + gh = h - mt - mb + if gw < 10 or gh < 10: + painter.end() + return + + # Grid + painter.setPen(QPen(self._grid, 1)) + painter.setFont(QFont("Consolas", 8)) + for tps_val in [0, 5, 10, 15, 20]: + y = mt + gh - (tps_val / 20.0) * gh + painter.drawLine(ml, int(y), w - mr, int(y)) + painter.setPen(QPen(QColor(140, 140, 160), 1)) + painter.drawText(5, int(y) + 4, f"{tps_val}") + painter.setPen(QPen(self._grid, 1)) + + # 15 TPS warning line + painter.setPen(QPen(QColor(255, 200, 60, 80), 1, Qt.DashLine)) + y15 = mt + gh - (15.0 / 20.0) * gh + painter.drawLine(ml, int(y15), w - mr, int(y15)) + + if len(self._data) < 2: + painter.setPen(QPen(QColor(140, 140, 160), 1)) + painter.setFont(QFont("Consolas", 12)) + painter.drawText(ml, mt, gw, gh, Qt.AlignCenter, "Waiting for data...") + painter.end() + return + + data_list = list(self._data) + now = data_list[-1][0] + time_window = 300.0 + + points = [] + for t, tps in data_list: + age = now - t + if age > time_window: + continue + x = ml + (1.0 - age / time_window) * gw + y = mt + gh - (min(tps, 20.0) / 20.0) * gh + points.append((x, y, tps)) + + for i in range(1, len(points)): + x1, y1, t1 = points[i - 1] + x2, y2, t2 = points[i] + avg = (t1 + t2) / 2 + + if avg >= 18: + color = self._line_good + elif avg >= 15: + color = self._line_warn + else: + color = self._line_bad + + painter.setPen(QPen(color, 2)) + painter.drawLine(int(x1), int(y1), int(x2), int(y2)) + + # Time axis + painter.setPen(QPen(QColor(140, 140, 160), 1)) + painter.setFont(QFont("Consolas", 8)) + for sec in [0, 60, 120, 180, 240, 300]: + x = ml + (1.0 - sec / time_window) * gw + if x >= ml: + label = "now" if sec == 0 else f"-{sec}s" + painter.drawText(int(x) - 15, h - 8, label) + + painter.end() + + +# ----------------------------------------------------------------------- +# Stat Card (reused from server-monitor) +# ----------------------------------------------------------------------- + +class StatCard(QFrame): + """Compact stat display card.""" + + def __init__(self, title: str, parent=None): + super().__init__(parent) + self.setFrameStyle(QFrame.Box | QFrame.Raised) + self.setStyleSheet( + "StatCard { background: #1a1a24; border: 1px solid #333348; border-radius: 6px; }" + ) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + + self._title = QLabel(title) + self._title.setStyleSheet("color: #8888aa; font-size: 11px;") + self._title.setAlignment(Qt.AlignCenter) + layout.addWidget(self._title) + + self._value = QLabel("--") + self._value.setStyleSheet("color: #ffffff; font-size: 24px; font-weight: bold;") + self._value.setAlignment(Qt.AlignCenter) + layout.addWidget(self._value) + + def set_value(self, text: str, color: str = "#ffffff"): + self._value.setText(text) + self._value.setStyleSheet( + f"color: {color}; font-size: 24px; font-weight: bold;" + ) + + +# ----------------------------------------------------------------------- +# Entity / Chunk Panel +# ----------------------------------------------------------------------- + +class EntityChunkPanel(QGroupBox): + """Per-dimension entity and chunk counts.""" + + def __init__(self, parent=None): + super().__init__("World Stats", parent) + self.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + + layout = QGridLayout(self) + layout.setSpacing(4) + + # Headers + headers = ["", "Overworld", "Nether", "End"] + for col, h in enumerate(headers): + lbl = QLabel(h) + lbl.setStyleSheet("color: #8888aa; font-size: 10px; font-weight: bold;") + lbl.setAlignment(Qt.AlignCenter) + layout.addWidget(lbl, 0, col) + + # Rows + row_labels = ["Entities", "Global", "Players", "Chunks", "Tile Ent.", "To Remove"] + self._cells: dict[tuple[str, int], QLabel] = {} + + for row, label in enumerate(row_labels, start=1): + lbl = QLabel(label) + lbl.setStyleSheet("color: #8888aa; font-size: 10px;") + layout.addWidget(lbl, row, 0) + + for col in range(3): + cell = QLabel("--") + cell.setStyleSheet("color: #ccccdd; font-size: 11px; font-family: Consolas;") + cell.setAlignment(Qt.AlignCenter) + layout.addWidget(cell, row, col + 1) + self._cells[(label, col)] = cell + + def update_from_snapshot(self, snap: TickSnapshot): + dim_order = [0, -1, 1] # Overworld, Nether, End + dim_to_col = {0: 0, -1: 1, 1: 2} + + level_map = {lv.dimension: lv for lv in snap.levels} + + for dim in dim_order: + col = dim_to_col[dim] + lv = level_map.get(dim) + + if lv: + self._set_cell("Entities", col, str(lv.entity_count)) + self._set_cell("Global", col, str(lv.global_entity_count)) + self._set_cell("Players", col, str(lv.player_count)) + self._set_cell("Chunks", col, str(lv.loaded_chunks)) + self._set_cell("Tile Ent.", col, str(lv.tile_entity_count)) + self._set_cell("To Remove", col, str(lv.entities_to_remove)) + else: + for label in ["Entities", "Global", "Players", "Chunks", "Tile Ent.", "To Remove"]: + self._set_cell(label, col, "--") + + def _set_cell(self, label: str, col: int, text: str): + cell = self._cells.get((label, col)) + if cell: + cell.setText(text) + + +# ----------------------------------------------------------------------- +# Memory Bar +# ----------------------------------------------------------------------- + +class MemoryBar(QWidget): + """Simple horizontal bar showing memory usage.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(24) + self.setMaximumHeight(30) + self._used_mb = 0.0 + self._total_mb = 1.0 + self._bg = QColor(24, 24, 32) + + def update_memory(self, used_mb: float, total_mb: float): + self._used_mb = used_mb + self._total_mb = max(total_mb, 1.0) + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + + painter.fillRect(0, 0, w, h, self._bg) + + ratio = min(self._used_mb / self._total_mb, 1.0) + bar_w = int(w * ratio) + + if ratio < 0.6: + color = QColor(80, 200, 120) + elif ratio < 0.8: + color = QColor(255, 200, 60) + else: + color = QColor(255, 80, 80) + + painter.fillRect(0, 2, bar_w, h - 4, color) + + painter.setPen(QPen(QColor(220, 220, 240), 1)) + painter.setFont(QFont("Consolas", 9)) + text = f"Memory: {self._used_mb:.0f} / {self._total_mb:.0f} MB ({ratio * 100:.0f}%)" + painter.drawText(8, 0, w - 16, h, Qt.AlignVCenter, text) + + painter.end() + + +# ----------------------------------------------------------------------- +# Lag Spike Panel +# ----------------------------------------------------------------------- + +class LagSpikePanel(QGroupBox): + """Shows recent lag spikes with the exact causal phase.""" + + def __init__(self, parent=None): + super().__init__("Lag Spikes", parent) + self.setStyleSheet( + "QGroupBox { color: #aaaacc; border: 1px solid #333348; " + "border-radius: 6px; margin-top: 8px; padding-top: 14px; }" + "QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }" + ) + + self._layout = QVBoxLayout(self) + self._layout.setSpacing(2) + + self._entries: deque[QLabel] = deque(maxlen=50) + self._placeholder = QLabel("No lag spikes detected") + self._placeholder.setStyleSheet("color: #50c878; font-size: 11px;") + self._placeholder.setAlignment(Qt.AlignCenter) + self._layout.addWidget(self._placeholder) + self._layout.addStretch() + + def add_spike(self, snap: TickSnapshot): + """Legacy - called without reason.""" + self.add_spike_with_reason(snap, "Unknown") + + def add_spike_with_reason(self, snap: TickSnapshot, reason: str): + """Add a spike entry with a classified reason.""" + if self._placeholder.isVisible(): + self._placeholder.setVisible(False) + + interval_ms = snap.total_us / 1000.0 + if interval_ms >= 1000: + dur_str = f"{interval_ms / 1000:.1f}s" + else: + dur_str = f"{interval_ms:.0f}ms" + + ts = time.strftime("%H:%M:%S") + text = f"[{ts}] {dur_str} - {reason}" + + # Color: red for major (>500ms), orange for moderate + if interval_ms >= 500: + color = "#ff5050" + else: + color = "#ffc83d" + + lbl = QLabel(text) + lbl.setStyleSheet(f"color: {color}; font-size: 10px; font-family: Consolas;") + lbl.setWordWrap(True) + + # Insert at top + self._layout.insertWidget(0, lbl) + self._entries.appendleft(lbl) + + # Remove old entries if over limit + while len(self._entries) > 50: + old = self._entries.pop() + self._layout.removeWidget(old) + old.deleteLater() + + def add_autosave(self, snap: AutosaveSnapshot): + """Add an autosave event.""" + if self._placeholder.isVisible(): + self._placeholder.setVisible(False) + + # Find dominant sub-phase + parts = [ + ("Players", snap.players_us), + ("Levels", snap.levels_us), + ("Rules", snap.rules_us), + ("Flush", snap.flush_us), + ] + cause, cause_us = max(parts, key=lambda x: x[1]) + + ts = time.strftime("%H:%M:%S") + text = (f"[{ts}] AUTOSAVE {snap.total_ms:.0f}ms " + f"- {cause} ({cause_us / 1000:.1f}ms)") + + lbl = QLabel(text) + lbl.setStyleSheet("color: #ffc83d; font-size: 10px; font-family: Consolas;") + + self._layout.insertWidget(0, lbl) + self._entries.appendleft(lbl) + + while len(self._entries) > 50: + old = self._entries.pop() + self._layout.removeWidget(old) + old.deleteLater()