perf(fourkit): server gc, fast-path event dispatch, tps probe

This commit is contained in:
itsRevela
2026-04-24 21:31:06 -05:00
parent 6db68bda06
commit b64b1f17db
11 changed files with 486 additions and 57 deletions
+88 -13
View File
@@ -20,6 +20,7 @@ Options:
import argparse
import logging
import math
import os
import random
import secrets
@@ -68,7 +69,9 @@ CIPHER_ON_PATTERN = (
b"\x00\x00"
)
CIPHER_KEY_CHANNEL = "MC|CKey"
CIPHER_ACK_CHANNEL = "MC|CAck"
CIPHER_ON_CHANNEL = "MC|COn"
IDENTITY_TOKEN_ISSUE = "MC|CTIssue"
IDENTITY_TOKEN_CHALLENGE = "MC|CTChallenge"
IDENTITY_TOKEN_RESPONSE = "MC|CTResponse"
@@ -130,14 +133,17 @@ class Stats:
# Movement packet builder
# ---------------------------------------------------------------------------
MOVE_PLAYER = 0x0D # MovePlayerPacket ID
MOVE_PLAYER = 0x0D # MovePlayerPacket::PosRot — what we send AND what server sends for teleports.
def build_move_player(x: float, y: float, z: float,
yaw: float, pitch: float, on_ground: bool) -> bytes:
# Wire order matches MovePlayerPacket::PosRot::write: x, y (feet),
# yView (eye), z, yaw, pitch, flags. Server kicks for IllegalStance
# if (yView - y) is outside [0.1, 1.65], so feet must come first.
dos = DataOutputStream()
dos.write_double(x)
dos.write_double(y + 1.62) # stance
dos.write_double(y)
dos.write_double(y + 1.62)
dos.write_double(z)
dos.write_float(yaw)
dos.write_float(pitch)
@@ -174,6 +180,13 @@ class StressBot:
self._identity_token = b""
self._entity_id = 0
self._running = True
# Server-tracked position. Initialized when server sends its first
# MovePlayer::PosRot teleport after login, and updated whenever the
# server teleports us (eg. plugin scatter, anti-cheat correction).
self._pos_x = 0.0
self._pos_y = 64.0
self._pos_z = 0.0
self._pos_initialized = False
def log(self, msg: str) -> None:
if not self.quiet:
@@ -250,8 +263,6 @@ class StressBot:
hold_end = time.time() + hold_time
last_keepalive = time.time()
keepalive_counter = 0
move_x, move_z = random.uniform(-50, 50), random.uniform(-50, 50)
move_y = 65.0
while time.time() < hold_end and self._running:
# Drain incoming data
@@ -276,13 +287,22 @@ class StressBot:
self.stats.keepalives_sent += 1
last_keepalive = now
# Movement packets every 50ms
if self.send_moves:
move_x += random.uniform(-0.5, 0.5)
move_z += random.uniform(-0.5, 0.5)
# Movement packets every 50ms. We can't do real travel because
# the server's anti-cheat compares our claimed position against
# what its own physics computes, and we don't simulate collision
# or gravity. Instead we drift ±0.3 blocks from whatever
# position the server most recently teleported us to. To spread
# bots out, use the test plugin's /fktest scatter from in-game.
if self.send_moves and self._pos_initialized:
new_x = self._pos_x + random.uniform(-0.3, 0.3)
new_z = self._pos_z + random.uniform(-0.3, 0.3)
yaw = random.uniform(0, 360)
self._send_packet(MOVE_PLAYER,
build_move_player(move_x, move_y, move_z, yaw, 0.0, True))
build_move_player(new_x, self._pos_y, new_z, yaw, 0.0, True))
# Optimistically update; server will correct us via PosRot
# if it disagreed (eg. we drifted into a block).
self._pos_x = new_x
self._pos_z = new_z
with self.stats.lock:
self.stats.moves_sent += 1
time.sleep(0.05)
@@ -294,11 +314,23 @@ class StressBot:
return True
def _do_cipher_scan(self) -> None:
"""Scan for cipher handshake for up to 3 seconds."""
"""Wait for the cipher handshake to finish or up to ~4s.
Returns early once both keys are exchanged. The upper bound has to
cover the worst case where a stack of plaintext setup packets
(level info, scoreboard, initial chunks) sits in front of MC|CKey
in the recv buffer. The server's own cipher-handshake grace is
100 ticks (~5s).
"""
scan_start = time.time()
scan_buf = bytearray()
while time.time() - scan_start < 0.5 and self._running:
while time.time() - scan_start < 4.0 and self._running:
# _handle_custom_payload may have already activated cipher via
# the drain path inside _read_until_packet.
if self._cipher_key and self._recv_cipher:
return
try:
chunk = self._sock.recv(65536)
if not chunk:
@@ -393,6 +425,14 @@ class StressBot:
pass
return True
# Handle cipher handshake during the login wait. With
# require-secure-client, the server holds the Login response
# behind the security gate until cipher activates, so the
# gate-bypass MC|CKey/MC|COn frames arrive before LOGIN.
# Dropping them here would deadlock both sides.
elif packet_id == CUSTOM_PAYLOAD:
self._handle_custom_payload(data)
elif packet_id == DISCONNECT:
try:
dis = DataInputStream(data)
@@ -428,16 +468,51 @@ class StressBot:
# Handle identity tokens
if packet_id == CUSTOM_PAYLOAD:
self._handle_custom_payload(data)
elif packet_id == MOVE_PLAYER:
self._handle_server_move(data)
def _handle_server_move(self, data: bytes) -> None:
"""Track server's view of our position. PosRot format:
double x, double y, double yView, double z, float yRot, float xRot, byte flags."""
try:
dis = DataInputStream(data)
x = dis.read_double()
y = dis.read_double()
_yView = dis.read_double()
z = dis.read_double()
self._pos_x = x
self._pos_y = y
self._pos_z = z
self._pos_initialized = True
except Exception:
pass
def _handle_custom_payload(self, data: bytes) -> None:
"""Handle identity token packets."""
"""Handle cipher handshake and identity token channels."""
try:
dis = DataInputStream(data)
channel = dis.read_utf()
length = dis.read_short()
payload = dis.read_raw(length) if length > 0 else b""
if channel == IDENTITY_TOKEN_ISSUE and len(payload) == 32:
# Cipher channels arrive in plaintext before encryption is active.
# Handle them here so the bot survives bursts where the whole
# handshake frame lands in a single recv(), bypassing the leftover
# byte-pattern scan.
if channel == CIPHER_KEY_CHANNEL and len(payload) == 32 and not self._cipher_key:
self._cipher_key = payload[:16]
self._cipher_iv = payload[16:32]
self.log(f"[{self.name}] got cipher key")
self._send_packet(CUSTOM_PAYLOAD,
build_custom_payload(CIPHER_ACK_CHANNEL))
iv_send = bytearray(self._cipher_iv)
iv_send[0] ^= 0x80
self._send_cipher = CipherState(self._cipher_key, bytes(iv_send))
elif channel == CIPHER_ON_CHANNEL:
if self._cipher_key and not self._recv_cipher:
self._recv_cipher = CipherState(self._cipher_key, self._cipher_iv)
self.log(f"[{self.name}] cipher active")
elif channel == IDENTITY_TOKEN_ISSUE and len(payload) == 32:
self._identity_token = payload
self.log(f"[{self.name}] got identity token")
elif channel == IDENTITY_TOKEN_CHALLENGE:
+14
View File
@@ -0,0 +1,14 @@
@echo off
REM FourKit chunk + move event stress: 50 concurrent moving bots held for 1-2min
REM each, exercising FireChunkLoad / FireChunkUnload / FirePlayerMove. Validates
REM the HasHandlers fast-path and Server GC at the 50-player target.
REM
REM Set require-secure-client=false in server.properties before running. The
REM 100-tick cipher handshake grace cannot keep up with 50 simultaneous bot
REM joins, which is unrelated to what this test is measuring.
set /p HOST="Server IP [127.0.0.1]: " || set HOST=127.0.0.1
set /p PORT="Server Port [25565]: " || set PORT=25565
if "%HOST%"=="" set HOST=127.0.0.1
if "%PORT%"=="" set PORT=25565
python "%~dp0stress_test.py" %HOST% %PORT% --bots 50 --burst 10 --move --hold 60 120 --ramp 0.5 --duration 600 --cycles 0 --quiet
pause