Skip to content

Internals

Thomas Jones edited this page Aug 17, 2026 · 1 revision

How minqlxtended finds its way around the engine, and how to measure what it costs. None of this is needed to write a plugin.

Engine offsets

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.

Byte patterns

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.

Measuring frame cost

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 us column 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 the sv_fps plugin 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 wait is 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; lower qlx_pythonSwitchInterval to bound it.
  • game event poll is the per-frame check behind round_end, game_end and team_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.

Finding out who held the GIL

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.

Clone this wiki locally