-
Notifications
You must be signed in to change notification settings - Fork 3
Internals
How minqlxtended finds its way around the engine, and how to measure what it costs. None of this is needed to write a plugin.
minqlxtended reads and writes the engine's own structs, so it has to know where every field
sits. Those offsets come from one list per struct in src/python/engine_fields.h: 572 rows
covering around 1,100 accessors, each carrying the field's byte offset, compiled out of the
real headers by tools/gen_field_offsets.py.
Two sets of asserts pin them:
| Assert | Checks | Fails when |
|---|---|---|
_Static_assert(offsetof(...)) in python_objects.c, one per struct across eighteen |
engine_fields.h against quake_common.h
|
A field is added, removed or reordered without regenerating |
_Static_assert at the end of each struct in quake_common.h
|
quake_common.h against the disassembly of build 1069 |
A Quake Live update moves something |
The first fails the build naming the Python attribute that would have started reading its neighbour. The second is the one place a QL update has to be answered by hand.
Regenerate after touching the method table, a struct-sequence description, the
PyModule_AddIntMacro block, or any struct in quake_common.h:
python3 tools/gen_stub.py # python/_minqlxtended.pyi and the package import block
python3 tools/gen_field_offsets.py # src/python/engine_fields.h
python3 tools/check_consistency.py # facts written down twice still agree
All three take --check, which reports rather than rewriting. That is what CI runs.
Every hook finds its target by searching for a byte pattern, which a Quake Live update can invalidate.
A miss is fatal. The server logs which pattern failed and refuses to start. There is no fallback: a reconstructed event isn't the same event, and a server that won't start gets noticed.
Patterns behind optional features are the exception. A miss warns at startup and costs that feature alone:
| Pattern | What you lose |
|---|---|
SelectScoreboardMessage |
The team scoreboard trim |
MP_AllowJoin, MP_PauseThink, MP_StopDemo
|
match_state, Game.lock/unlock. All three are needed together, since the first gives the base of the match-play block and the other two pin its ends, so a miss on any one is refused as a set |
MP_LockOrUnlockTeam |
Game.lock/unlock, leaving match_state readable |
G_SpawnGEntityFromSpawnVars |
spawn_entity() |
SV_LinkEntity, SV_UnlinkEntity
|
link_entity(), unlink_entity()
|
sv is the other thing that can fail without stopping the server. Nothing exports it, so it is
derived by following SV_GetConfigstring's read of sv.configstrings back to the front of the
struct, bounds-checked against the loaded image. A failure is reported at startup and
minqlxtended.server raises EngineStateError. svs resolves separately, so
minqlxtended.server_static still works.
minqlxtended hooks the dedicated server's main loop, so anything on the game thread comes out
of the frame budget: 25 ms at the default sv_fps 40, proportionally less if you raise it.
qlx_prof on # start sampling (also resets the counters)
qlx_prof # print the report
qlx_prof reset # zero the counters
qlx_prof off # stop sampling
Sampling is off by default and costs nothing while off, so release builds ship with it compiled in. Each row is one probe: an event dispatcher, the demo capture path, or the frame as a whole.
Read the
max uscolumn first. A high average is a throughput problem and rarely the one that hurts. A single 5 ms outlier inside a 25 ms frame is what players feel, and on a server running thesv_fpsplugin the frame can be 5 to 12 ms.
Three rows behave differently:
-
frame (incl. engine)is the whole frame including the engine's own work, so it is the denominator everything else is measured against, not an overhead figure of its own. -
GIL waitis time the game thread spent blocked waiting to enter Python, kept separate from the work. A large max means a background thread is holding the GIL too long; lowerqlx_pythonSwitchIntervalto bound it. -
game event pollis the per-frame check behindround_end,game_endandteam_switch. It runs every frame and is only integer comparisons. An outlier is a frame where it found something and entered Python, which is also counted in that event's own row.
Totals for nested dispatches overlap, so don't sum the total ms column. A plugin sending a
server command from inside a client_command handler is counted in both rows. Counts and
maxima are always exact.
qlx_prof times the GIL wait but can't say who was holding it. qlx_pyperf turns on CPython's
perf trampoline so Python frames appear in a native profile:
qlx_pyperf on
perf record -g -p $(pidof qzeroded.x64)
perf report # names Python functions instead of stopping at _PyEval_EvalFrameDefault
qlx_pyperf off
It needs an interpreter built with perf trampoline support: Linux, 3.12 or later. Anywhere else
the command says so and does nothing. CPython compiles a trampoline per code object the first
time it runs, so leave it off unless perf is recording.