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.
This commit is contained in:
itsRevela 2026-04-05 15:49:52 -05:00
parent 463bf2b93f
commit be17d4028f
19 changed files with 3770 additions and 0 deletions

View file

@ -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)

View file

@ -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

View file

@ -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}")

View file

@ -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$<$<CONFIG:Debug>:Debug>"
)

View file

@ -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 <cstdio>
#include <cstdarg>
// -----------------------------------------------------------------------
// Globals
// -----------------------------------------------------------------------
std::atomic<bool> g_hooksInstalled{false};
std::atomic<bool> 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<std::mutex> 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<uintptr_t>(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;
}

View file

@ -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 <psapi.h>
#include <cstring>
// -----------------------------------------------------------------------
// 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<LevelMetrics> &out)
{
LevelMetrics localLevels[3] = {};
uint32_t levelCount = 0;
__try {
auto base = reinterpret_cast<uint8_t *>(thisPtr);
auto levelsData = *reinterpret_cast<void ***>(base + 24);
auto levelsLength = *reinterpret_cast<uint32_t *>(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<TickFn>(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> 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<const uint8_t *>(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<void *>(tickAddr), 32);
// Allocate trampoline
g_trampoline = static_cast<uint8_t *>(
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<const uint8_t *>(tickAddr),
g_trampoline, HOOK_SIZE);
LogWrite("Copied %d prologue bytes", g_prologueLen);
// Log decoded instructions
{
const uint8_t *p = reinterpret_cast<const uint8_t *>(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<uint32_t *>(jmpBack + 2) = 0;
*reinterpret_cast<uintptr_t *>(jmpBack + 6) = resumeAddr;
// Patch tick() entry to JMP to HookedTick
DWORD oldProtect;
if (!VirtualProtect(reinterpret_cast<void *>(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<uint8_t *>(tickAddr);
patch[0] = 0xFF;
patch[1] = 0x25;
*reinterpret_cast<uint32_t *>(patch + 2) = 0;
*reinterpret_cast<uintptr_t *>(patch + 6) = reinterpret_cast<uintptr_t>(&HookedTick);
VirtualProtect(reinterpret_cast<void *>(tickAddr), HOOK_SIZE, oldProtect, &oldProtect);
FlushInstructionCache(GetCurrentProcess(), reinterpret_cast<void *>(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<void *>(g_tickAddr), 32,
PAGE_EXECUTE_READWRITE, &oldProtect);
memcpy(reinterpret_cast<void *>(g_tickAddr), g_originalBytes, g_prologueLen);
VirtualProtect(reinterpret_cast<void *>(g_tickAddr), 32,
oldProtect, &oldProtect);
FlushInstructionCache(GetCurrentProcess(), reinterpret_cast<void *>(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");
}

View file

@ -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 <cstdio>
// 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);
}

View file

@ -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 <psapi.h>
#include <algorithm>
// -----------------------------------------------------------------------
// Snapshot queues (game thread pushes, TCP thread drains)
// -----------------------------------------------------------------------
static std::mutex g_tickMtx;
static std::deque<TickSnapshot> g_tickQueue;
static constexpr size_t MAX_TICK_QUEUE = 200; // ~10 seconds at 20 TPS
static std::mutex g_autoMtx;
static std::deque<AutosaveSnapshot> g_autoQueue;
static constexpr size_t MAX_AUTO_QUEUE = 20;
// -----------------------------------------------------------------------
// Initialization
// -----------------------------------------------------------------------
void MetricsInit()
{
}
void MetricsShutdown()
{
std::lock_guard<std::mutex> lock1(g_tickMtx);
g_tickQueue.clear();
std::lock_guard<std::mutex> 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<shared_ptr<Entity>>) 24 bytes
// offset 88: entitiesToRemove (vector<shared_ptr<Entity>>) 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<shared_ptr<TileEntity>>) 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<shared_ptr<Player>>) 24 bytes
// offset 264: globalEntities (vector<shared_ptr<Entity>>) 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<shared_ptr<T>> on MSVC x64:
// +0: _Myfirst (pointer to first element)
// +8: _Mylast (pointer past last element)
// +16: _Myend (pointer past allocated capacity)
// sizeof(shared_ptr<T>) = 16 (raw ptr + ref count ptr)
static int ReadVectorSize(uint8_t *vecBase, int elementSize)
{
auto first = *reinterpret_cast<uintptr_t *>(vecBase + 0);
auto last = *reinterpret_cast<uintptr_t *>(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<uint8_t *>(serverLevel);
g_scanCount = 0;
__try {
for (int off = 0; off < 400 && g_scanCount < MAX_SCAN_CANDIDATES; off += 8) {
auto first = *reinterpret_cast<uintptr_t *>(base + off);
auto last = *reinterpret_cast<uintptr_t *>(base + off + 8);
auto end = *reinterpret_cast<uintptr_t *>(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<T> (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<uint8_t *>(serverLevel);
__try {
for (int off = 56; off < 320; off += 8) {
auto first = *reinterpret_cast<uintptr_t *>(base + off);
auto last = *reinterpret_cast<uintptr_t *>(base + off + 8);
auto end = *reinterpret_cast<uintptr_t *>(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<uint8_t *>(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<GetChunkMapFn>(g_symbols.ServerLevel_getChunkMap);
auto chunkMap = reinterpret_cast<uint8_t *>(fn(serverLevel));
if (chunkMap) {
static int chunkCountOffset = -1;
if (chunkCountOffset < 0) {
for (int off = 24; off < 120; off += 8) {
auto val = *reinterpret_cast<size_t *>(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<size_t *>(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<std::mutex> 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<std::mutex> lock(g_autoMtx);
if (g_autoQueue.size() >= MAX_AUTO_QUEUE) {
g_autoQueue.pop_front();
}
g_autoQueue.push_back(std::move(snap));
}
std::vector<TickSnapshot> DrainTickSnapshots()
{
std::lock_guard<std::mutex> lock(g_tickMtx);
std::vector<TickSnapshot> out(g_tickQueue.begin(), g_tickQueue.end());
g_tickQueue.clear();
return out;
}
std::vector<AutosaveSnapshot> DrainAutosaveSnapshots()
{
std::lock_guard<std::mutex> lock(g_autoMtx);
std::vector<AutosaveSnapshot> out(g_autoQueue.begin(), g_autoQueue.end());
g_autoQueue.clear();
return out;
}

View file

@ -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 <cstdint>
#include <atomic>
#include <mutex>
#include <vector>
#include <string>
#include <deque>
#include <windows.h>
// -----------------------------------------------------------------------
// 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<LevelMetrics> 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<bool> 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<bool> 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<TickSnapshot> DrainTickSnapshots();
std::vector<AutosaveSnapshot> 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();

View file

@ -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 <dbghelp.h>
#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<SYMBOL_INFO *>(buf);
memset(buf, 0, sizeof(buf));
sym->SizeOfStruct = sizeof(SYMBOL_INFO);
sym->MaxNameLen = MAX_SYM_NAME;
if (SymFromName(hProcess, name, sym)) {
outAddr = static_cast<uintptr_t>(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;
}

View file

@ -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 <winsock2.h>
#include <ws2tcpip.h>
#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<bool> g_running{false};
static std::mutex g_clientsMtx;
static std::vector<SOCKET> 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<const char *>(header), 4, 0);
send(sock, json.c_str(), (int)json.size(), 0);
}
static void RemoveClient(SOCKET sock)
{
closesocket(sock);
std::lock_guard<std::mutex> 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<sockaddr *>(&clientAddr),
&addrLen);
if (clientSock == INVALID_SOCKET) continue;
// Check client limit
{
std::lock_guard<std::mutex> 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<const char *>(&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<std::mutex> 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<std::string> 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<SOCKET> deadClients;
{
std::lock_guard<std::mutex> 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<const char *>(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<const char *>(&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<u_short>(port));
if (bind(g_listenSock, reinterpret_cast<sockaddr *>(&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<std::mutex> 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");
}

View file

@ -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)

View file

@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
python inject.py %*
pause

View file

@ -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()

View file

@ -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()

View file

@ -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),
)

View file

@ -0,0 +1 @@
PySide6>=6.6

View file

@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
python main.py %*

View file

@ -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()