neoLegacy/tools/performance-monitor/inject.py
itsRevela be17d4028f 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.
2026-04-05 15:49:52 -05:00

295 lines
10 KiB
Python

#!/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()