mirror of
https://github.com/neoStudiosLCE/neoLegacy.git
synced 2026-06-09 00:33:00 +00:00
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:
parent
463bf2b93f
commit
be17d4028f
425
tools/performance-monitor/bots.py
Normal file
425
tools/performance-monitor/bots.py
Normal 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)
|
||||
25
tools/performance-monitor/build-dll.bat
Normal file
25
tools/performance-monitor/build-dll.bat
Normal 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
|
||||
157
tools/performance-monitor/connection.py
Normal file
157
tools/performance-monitor/connection.py
Normal 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}")
|
||||
33
tools/performance-monitor/dll/CMakeLists.txt
Normal file
33
tools/performance-monitor/dll/CMakeLists.txt
Normal 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>"
|
||||
)
|
||||
172
tools/performance-monitor/dll/src/dllmain.cpp
Normal file
172
tools/performance-monitor/dll/src/dllmain.cpp
Normal 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;
|
||||
}
|
||||
337
tools/performance-monitor/dll/src/hooks.cpp
Normal file
337
tools/performance-monitor/dll/src/hooks.cpp
Normal 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");
|
||||
}
|
||||
132
tools/performance-monitor/dll/src/json_writer.cpp
Normal file
132
tools/performance-monitor/dll/src/json_writer.cpp
Normal 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);
|
||||
}
|
||||
384
tools/performance-monitor/dll/src/metrics.cpp
Normal file
384
tools/performance-monitor/dll/src/metrics.cpp
Normal 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;
|
||||
}
|
||||
143
tools/performance-monitor/dll/src/perf_monitor.h
Normal file
143
tools/performance-monitor/dll/src/perf_monitor.h
Normal 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();
|
||||
120
tools/performance-monitor/dll/src/symbols.cpp
Normal file
120
tools/performance-monitor/dll/src/symbols.cpp
Normal 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;
|
||||
}
|
||||
297
tools/performance-monitor/dll/src/tcp_server.cpp
Normal file
297
tools/performance-monitor/dll/src/tcp_server.cpp
Normal 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");
|
||||
}
|
||||
557
tools/performance-monitor/gui.py
Normal file
557
tools/performance-monitor/gui.py
Normal 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)
|
||||
4
tools/performance-monitor/inject.bat
Normal file
4
tools/performance-monitor/inject.bat
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python inject.py %*
|
||||
pause
|
||||
294
tools/performance-monitor/inject.py
Normal file
294
tools/performance-monitor/inject.py
Normal 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()
|
||||
70
tools/performance-monitor/main.py
Normal file
70
tools/performance-monitor/main.py
Normal 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()
|
||||
122
tools/performance-monitor/models.py
Normal file
122
tools/performance-monitor/models.py
Normal 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),
|
||||
)
|
||||
1
tools/performance-monitor/requirements.txt
Normal file
1
tools/performance-monitor/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
PySide6>=6.6
|
||||
3
tools/performance-monitor/start.bat
Normal file
3
tools/performance-monitor/start.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python main.py %*
|
||||
494
tools/performance-monitor/widgets.py
Normal file
494
tools/performance-monitor/widgets.py
Normal 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()
|
||||
Loading…
Reference in a new issue