feat: minqlxtended as a selectable server runtime - #193
Conversation
…site reconcile_instance_after_host_setup builds its own extravars dict for sync_instance_configs_and_restart.yml and was missed as a sixth production call site. Every Host Setup rerun reconciles every instance on the host through this path, so a minqlxtended instance silently got minqlx defaults (wrong plugins dir, wrong shared dir, wrong ExecStart).
ConfigPreset now carries a runtime column, stamped from the host of the instance a preset is saved from and defaulting to minqlx for every existing row. The value survives export/import zips, the built-in preset sync, and the full-DB backup round trip. Also exposes host_runtime on QLInstance so the Edit Instance Config modal can thread the host's runtime down to the preset save form, since that view only has the instance API response to draw from (no separate host prop).
…nt objects The no_autoflush wrapper suppressed flush semantics on a read path every preset serialization goes through, including minqlx ones, purely to accommodate a scenario the ORM can never produce in practice (the column is NOT NULL with a server_default, so no persisted row can ever read back as None). Reverted from ConfigPreset.to_dict() and _preset_export_manifest. The realistic "no runtime recorded" case is an old export manifest with no runtime key, which only ever reaches the import path. Rewrote the two model/route-level tests to use transient ConfigPreset objects (never added to the session, so no expiry/autoflush is involved and the in-memory None default is read directly), and added import-side coverage: a manifest with no runtime key lands on minqlx on both the create and overwrite branches.
The overwrite path never stamped runtime, so overwriting an existing preset with content saved from a different-runtime host left a stale tag -- mismatched content passing the runtime gate Task 11 relies on, the same hazard class that gate exists to close. Threaded runtime through all three overwrite call sites (PresetSaveTab, AddInstanceForm, EditInstanceConfigModal) to update_preset_api. Update semantics differ from create deliberately: an absent runtime leaves the column untouched rather than defaulting to minqlx, since this endpoint also serves plain rename/description edits with no originating host -- defaulting would silently downgrade an existing minqlxtended preset.
The runtime gate only fired once a host was selected, so two sequences reached instance creation with a mismatched config: loading a preset with no host chosen (the compatibility helper allows everything when the host is unknown), and switching to a host on the other runtime after loading. Load Preset is now disabled until a host is chosen, with a tooltip saying why, and switching to a host on the other runtime clears the loaded preset and says so rather than carrying it over silently. A same-runtime switch leaves it alone -- discarding the operator's config there would be worse than the bug being fixed. handleHostChange reads the loaded preset through a ref instead of taking it as a useCallback dependency. It must not take it as one: the mount effect depends on handleHostChange's identity and calls setLoadedPreset (null), so a new identity on every preset load re-ran that reset and wiped the preset just loaded. Also extracts PresetManagerModal's Load Selected guard to a named const, putting the file back under the 300-LOC limit it crossed in the previous commit.
Registers ql-assets/data/minqlxtended-plugins/ as a second backup tree alongside the existing minqlx one, so a restore onto a fresh machine carries both runtimes' baselines. Updates architecture.md, technical.md, api_reference.md, development_guidelines.md, and the user-facing add-host and presets pages to describe the minqlx/minqlxtended runtime split that ui/runtime.py resolves throughout the stack.
… mention Standalone host creation rejects a too-old Python synchronously (SSH probe runs before create_host()); self-host creation has no SSH session to probe with, so it always returns 201 and the Python floor is only enforced later by setup_host.yml's assert, landing the host in ERROR on failure. Both api_reference.md and the add-host user doc previously claimed self hosts were gated the same way as standalone. Also generalized architecture.md's "/home/ql/minqlx-shared, etc." to name both runtime shared dirs.
…viewer The migration downgrades for Host.runtime and ConfigPreset.runtime dropped the column unconditionally, which silently backfills every row to 'minqlx' on the next upgrade. For a host actually running minqlxtended, the next terraform apply -- including a routine resize -- would then reinstall its OS to match. Both downgrades now refuse when a non-minqlx row exists. The MinQLX log viewer was still minqlx-only on the frontend: it filtered the backend's runtime-scoped file list against a hardcoded 'minqlx.log' pattern (dropping every minqlxtended.log entry) and defaulted the fetch filename to 'minqlx.log' regardless of the instance's runtime, so opening it on a minqlxtended instance always came up empty with a 400. The filter is gone -- the backend list is already validated, so the frontend just orders it. The api.js filename default is gone too, letting the backend apply its own per-runtime default when the frontend hasn't resolved one yet. The initial file selection is now seeded from the instance's own runtime via a new runtimeLogFilename() lookup in constants/runtimes.js, which also fixes a request-doubling bug the naive fix introduced (caught by a dedicated regression test).
…erically
PresetSaveTab stamped `host?.runtime || 'minqlx'`, which normalises a null or
absent host but forwards any other unexpected value straight to the API, and
hardcodes the default the constants module already owns. runtimeLabel() does
both jobs and is what the rest of the frontend uses.
add_qlds_instance.yml's task still announced "Create minqlx-plugins directory"
after its path became {{ runtime_plugins_dirname }}, so `ansible --list-tasks`
read wrong on a minqlxtended deployment.
Both raised in the PR review of #180.
detect_remote_os now runs a third SSH command to probe the host's python3 version, but the pre-existing tests in test_standalone_ssh.py stub exec_command with a two-entry side_effect, so the extra call raised StopIteration. Each of the five affected tests now supplies the probe's response, and a new test covers the case the probe was written for: a host with no python3 reports python_version None instead of failing detection. test_terraform_runtime_os.py asserted that each entry point reached Terraform, but all three return early when `terraform` is missing from PATH. That is true of the CI runner, which installs no terraform, so the test passed locally and failed there. It now stubs shutil.which, and keeps the exception it used to swallow so a failure reports why nothing was captured rather than just showing an empty list. Both surfaced by the Tests workflow on #180.
…-plumbing P1: per-host minqlx/minqlxtended runtime plumbing
…eset New instances seeded plugins and hooks from the builtin preset literally named 'default' -- the minqlx one -- for every host, and _seed_draft overlaid that same preset's scripts/ beneath any non-default preset. On a minqlxtended host both paths ship minqlx plugin files and minqlx module names, which is what the preset Load-tab runtime gate exists to prevent, arriving through a path that gate never sees. Both seeding paths now resolve the builtin preset from the runtime: defaultPresetNameForRuntime() on the frontend, _default_preset_for() on the backend. AddInstanceModal fetches both builtin presets up front and hands the form defaultSeedsByRuntime; the form picks the seed matching the selected host and re-seeds when a host switch crosses runtimes. Claude-Session: https://claude.ai/code/session_01FtitdSf3kcqzv9hET9fqyg
Every file in the real default preset opens with 'import minqlx', so an unmodified minqlx -> minqlxtended filter strips all 53, leaving both reported_kept and on_disk as the empty set. The final on_disk == reported_kept assertion therefore passed against a filter that deletes every .py unconditionally, ignoring per-file classification -- confirmed with a working exploit patch to _apply_runtime_filter (revert not included in this commit). Fix: seed a copy of the real preset with one file's content swapped for its minqlxtended-baseline counterpart (aliases.py, already known to hash-match), so the fixture has something to keep as well as something to strip. Added the explicit 'reported_kept must be non-empty' guard the reviewer asked for, alongside the existing 'something was stripped' guard, so the two together pin both ends. Also corrected the docstring, which had claimed the test exercises the hash-drifted 'unknown' files from the 53 -> 40 regression -- it does not; every file here fails via the content scanner, not a missing hash match, and that distinction only matters on the matched-runtime path tested separately above.
Three fixes to the delete loop and the seam around it, all narrowing. 1. CRITICAL -- _seed_draft copies the TARGET runtime's builtin default preset in first, overlays the source preset, then filters EVERYTHING. 13 of the target's own 53 shipped files have drifted from the ql-assets manifest, so they miss the hash allow-list, land `unknown` and were deleted. 9 of them appear in no dialog at all, because the dialog is computed from the SOURCE preset: commlink, iouonegirl, mybalance, mydiscordbot, the four discord_extensions/ modules and extras/textart. _apply_runtime_filter now exempts any file whose content digests equal what the target runtime's own default preset ships at that relative path. By content, not by name, so a source file that merely reuses a shipped filename is still classified. Reproduction (minqlxtended preset -> minqlx host, builtins synced): 32 files deleted out of the target's own overlay, 9 of them unreported -> 23 deleted, 0 unreported. 2. CRITICAL rider -- _resolve_source_runtime returns None for a missing preset/host row and normalize_runtime(None) is 'minqlx', so "unknown source" was indistinguishable from "explicitly minqlx" and the filter ran against a minqlxtended target. create_draft now passes an UNRESOLVED_RUNTIME sentinel and the filter declines to run. None keeps its "untagged is minqlx" meaning for every other caller. 3. Overwrite Preset refuses a cross-runtime draft. The PUT rmtree's the preset's scripts and copytree's the draft in; with the load block removed, a minqlx preset edited on a minqlxtended instance would have its plugins replaced by the minqlxtended-filtered set. A filtered draft now records the runtime it was filtered for and the overwrite 400s rather than converting the preset behind the operator's back. Tests: a new app_with_builtin_presets fixture seeds the builtin preset rows, because the plain `app` fixture leaves ConfigPreset empty, which makes _seed_draft's default-overlay branch dead in every test in the repo while production always takes it -- that is where (1) hid. The disk-truth oracle moves from report['scripts'], which no frontend code reads, to report['compatibility']['stripped'], which the dialog renders. Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
- useDraftWorkspace built its effect key with join(' ') and rebuilt the
list with split(' '). A filename containing a space does not round-trip,
and it fails badly: 'a b.py' splits into ['a', 'b.py'], so if b.py is
itself a candidate the accept silently retargets to a different file.
JSON-encode the sorted array instead. Not reachable today -- every
shipped plugin name is space-free -- which is exactly why nothing would
notice when one is not.
- The compatibility dialog said the listed plugins won't be installed,
which is still true, but with the overlay fix a cross-runtime load now
legitimately installs the target runtime's own plugins that were never
listed. The copy now says so rather than leaving the operator to find
out on the next screen.
- api_reference claimed "Only root-level .py files are classified". False:
subdirectory files are classified and stripped (discord_extensions/
admin.py shows up in real stripped output); root-level is only the rule
for OFFERING a replacement. Its example also showed mybalance.py
offered balance.py, which _strip_entry cannot produce -- replacements
are same-name-only. Both corrected, and the new PUT refusal documented.
Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
…olicy The minqlxtended half of the PR #100 regression; PR #187 carries the full history, evidence, and live-host verification, and is now merged to main. sv_serverType is derived from effective_lan_rate(instance), not from instance.lan_rate_enabled -- matching the sv_lanForceRate line directly above it. QLSM fixes 99k on for every minqlxtended host as a policy choice (lan_rate_forced_on), while the stored column keeps whatever it happened to hold, usually False because the toggle is disabled in the UI on that runtime. Keying the new cvar to the raw column would hand every minqlxtended instance sv_serverType 2 alongside sv_lanForceRate 1 -- the exact broken pairing this fix exists to remove, with no toggle to escape it. A naive merge of the main-side fix does precisely that, so there is a test pinning it. The tests were verified against mutants, not merely run: the forced-99k test fails when sv_serverType is derived from the raw column, and the pairing test fails against PR #100's exact code. Deliberately NOT touching the default presets. An earlier revision of this branch commented out `set sv_serverType "2"` in both _builtin/default and _builtin/default-minqlxtended, on the premise that a literal value in server.cfg would fight the launch args. Live testing disproved that premise: a deployed instance whose server.cfg:67 said "2" reported sv_serverType 1 at runtime, because the command line wins for this cvar exactly as PR #75 found for net_ip (CVAR_INIT). The preset lines are inert, so editing them was cosmetic -- and it pulled _builtin/default, a file shared with main, into a minqlxtended-only PR for no functional gain. No version bump -- phase PR into the integration branch. Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
…he forced 99k policy
…tooltips The runtime a host builds is immutable once the host exists, so a pre-selected value means an operator who never looked at the field still made an irreversible decision. The picker now offers both runtimes as radio buttons with neither checked, and the form refuses to submit until one is chosen. This replaces the planned "flip the default to minqlxtended" scope. No runtime is the default now: minqlxtended does not become one and minqlx stops being one. DEFAULT_RUNTIME survives in both ui/runtime.py and constants/runtimes.js, but only for the two cases where no human is choosing -- a host row predating the runtime column, and an API payload that omits the field. Each option carries an InfoTooltip with a description, a link to its upstream repo, and a closing line that depends on the provider. The tail is provider-aware because one fixed string would be wrong somewhere: QLSM writes the OS image on a cloud host, but on standalone and self it provisions no OS at all, and only minqlxtended has a floor to miss. That tail names the distro rather than the Python version deliberately. minqlxtended is compiled on the host against libpython3.12 (setup_host.yml:665), Debian 12 ships no python3.12 in its archive, and setup installs the distro's own python3 rather than any specific version (setup_host.yml:297) -- so setup cannot rescue a host below the floor, and "use Ubuntu 24.04" is actionable where "install Python 3.12" is a dead end. The backend is unchanged: an absent runtime in a create-host payload still resolves to minqlx, which is the conservative direction for any caller that is not this form. Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
…tests Review finding: the "Server runtime is required." message went to the modal's generic error slot at the bottom of the form, which is a plain div -- no role, no aria-live, and no association with the field. A screen-reader user landing on the radio group was told nothing, and sighted users got the message a long way from the radios it describes. The message now renders directly under the fieldset with role="alert" and an id the fieldset points at via aria-describedby, and it clears the moment a runtime is picked. AddHostModal tracks it as runtimeError, alongside nameError, rather than through the shared error slot. Declined the review's second finding, that name="host-runtime" could collide across two mounted instances: AddHostFormFields is rendered in exactly one place, the radios are controlled so React restores checked from state regardless, and StandaloneAuthSection already uses a fixed name for the same reason. Also fixes real flakiness in three tests I added in the previous commit. They waited for getHosts to have been called and then reached straight for a field by label, but the modal gates its fields on provider options being ready, so the query could run against a form that had not rendered yet -- one run in three failed. They now wait on the DOM. Verified with five consecutive clean runs of the file. Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
The JSDoc claimed {string} while the runtime picker passes a JSX tree
(description, provider tail, repo link). Nothing breaks -- the component
has no PropTypes and renders {text} straight into JSX -- but the
annotation was telling the next caller something false.
Kept the prop name. Renaming it to content/children would touch 21 call
sites for no behaviour change.
Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
…th per-runtime tooltips feat: make the Add Host runtime an explicit choice, with per-runtime tooltips
The migration updated add-host, presets, 99k LAN rate and deploy-new-instance, but four pages still described a minqlx-only world. Each of these is something an operator on a minqlxtended host would read and be misled by. - minqlx-logs: the page named `minqlx.log` throughout. A minqlxtended host writes `minqlxtended.log`, and the modal already selects the right file -- only the documentation was wrong. Rewritten to name both and say the menu item is called "View MinQLX Logs" either way. - hooks: said force_rate.so is registered whenever 99k LAN rate is enabled. On minqlxtended every instance runs at 99k and force_rate.so is deliberately never loaded, because the fork patches the same function itself and loading both stops the server from starting. An operator seeing 99k with no hook would reasonably think something was missing. - deployment-troubleshooting: had nothing on the Python floor. Added the failure mode, split by provider, because only the self-host path can reach Error this way -- cloud is provisioned correctly and standalone is refused at the form. Says to change the distribution rather than install a package, since Debian 12 has no python3.12 to install. - introduction: the feature list never mentioned that a host has a runtime at all. Also corrected the 99k entry, which described the LD_PRELOAD mechanism as though it were the only one. Verified with `mkdocs build --strict`: exit 0, no warnings, all new cross-links resolve. Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
Brings the integration branch up to date with main through v1.27.3, so the final PR to main is not carrying 13 commits of drift on top of everything else. Four files conflicted; the 99k LAN rate doc auto-merged. - ui/task_logic/ansible_instance_mgmt.py: took HEAD. main (PR #187) keys sv_serverType to instance.lan_rate_enabled; this branch (PR #188) keys it to effective_lan_rate(), which is the superset -- a minqlxtended host is forced to 99k with the column still reading 0, and the raw column would hand it sv_serverType 2 alongside sv_lanForceRate 1. This resolution was pre-agreed in the migration status doc. - tests/test_task_deploy_instance.py: took both sides. main refined the docstring; this branch adds the forced-99k minqlxtended case that main has no counterpart for. - docs/user/operations/edit-configs.md: took both rows. main documented sv_serverType (PR #187); this branch added the minqlxtended caveat to sv_lanForceRate. The new sv_serverType row needs the same caveat, since forced 99k pins both cvars. - ql-assets/data/minqlx-plugins/manifest.json: regenerated via scripts/gen_plugin_manifest.py. main changed myFun.py (console-command injection guard on !sound, 971c5dd) and player_info.py (HTTP timeout, 8f4c8e3) after this branch generated the manifest, so the recorded hashes no longer matched the files. test_plugin_manifests caught it. Both fixes are already present in the minqlxtended ports and both builtin presets -- only the hashes were stale. Verified: backend 1938 passed, frontend 739 passed, lint 0 errors. One pre-existing failure is unrelated and predates this merge (it fails at 1275b05 too): test_script_routes.py::TestValidateScript::test_validate_invalid_python. ruff 0.15.10 reports syntax errors as code "invalid-syntax", and _validate_python_content only counts a finding as an error when its code starts with "F", so the endpoint calls broken Python valid. CI installs no ruff and falls through to py_compile, which is why CI is green. Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
…ng from docs: cover minqlxtended in the user docs it was missing from
Preset export walked the whole preset directory and only filtered *.pyc, *.pyo, *.swp, *.tmp and *~, so an editor/tooling backup sitting beside a script (ranked.py.bak-pre-player-ip-connected-20260704-222233) went into the ZIP. Import only accepts .py/.txt/.so/font extensions under scripts/ and hard-fails the whole archive on anything else, so QLSM was producing archives QLSM itself rejected with 400 "Unsupported script file". Add ARCHIVE_EXCLUDED_PATTERNS (*.bak, *.bak-*, *.bak.*, *.orig, *.rej) to _should_skip_export_path, which export and import both call -- so archives already exported with a stray backup now import cleanly instead of needing a re-export. Keep _ignore_generated_script_cruft on the narrower list. It feeds the copytree that repopulates a preset's scripts directory after an rmtree, so filtering backups there would delete the user's own .bak files on the next preset save.
The first pass kept .bak/.orig/.rej out of export archives but deliberately let them through on draft save, on the premise that saving a preset rewrites "the user's real scripts directory" and filtering there would delete their backups. That premise is wrong. Both callers of _ignore_generated_script_cruft (preset_api_routes.py:1144 and :1413) write into a preset's scripts dir -- neither touches a live instance config -- and each rmtree's that directory before copying the draft over it, so nothing of the user's survives there anyway. There are no .bak files in configs/presets to preserve. Worse, the asymmetry created the divergence it was supposed to avoid: a preset on disk could hold files its own export would drop, and those files could never survive a round-trip because the import validator rejects their extensions. A preset is a curated artefact, so one pattern set now covers export, import and draft save. That collapses the _matches_excluded_path split the asymmetry needed, and inverts the draft-save test: a backup reaching a draft must not be written into the preset. The import-side filter stays: it lets archives that already contain backups import cleanly instead of failing with "Unsupported script file". Verified: 0 of 542 real files under configs/presets match the patterns. Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
Review found a real gap: the draft->preset scripts copy passes
ignore=_ignore_generated_script_cruft, but the user-hooks copy beside it
(preset_api_routes.py:1140 and :1409) passed no ignore at all.
That reopens the exact divergence this branch exists to close. The export
walk covers the whole preset directory, user-hooks included, so a backup
landing there would be written into the preset and then dropped from the
preset's own archive.
Both copies now share the filter. The draft-save test grows a user-hooks
case; verified non-vacuous by removing the ignore= argument and watching it
fail.
Also adds a *.bak.* fixture to the download test, which only covered *.bak
and *.bak-* on the export side.
Declined from the same review: tightening *.rej to scripts/ only -- .rej is
not in ALLOWED_PRESET_CONFIG_EXTENSIONS ({'.cfg', '.txt', '.ent'}), so the
hypothetical configs/acl.rej could not exist in a valid preset; and a direct
unit test for _ignore_generated_script_cruft, a six-line wrapper already
covered through both public paths.
Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
create_preset_api and update_preset_api each carry their own near-identical
draft->preset block, so the previous test proved the filter only on the
update path. Review was right to flag it, and this repo has been bitten by
that shape before (P1's missed call site, P2's two hardcoded preset names).
Verified the new test is not decorative: removing ignore= from the create
call site alone fails the create test and leaves the update test green.
Also: _ignore_generated_script_cruft's docstring still said "draft scripts"
after it started serving user-hooks, and the pattern comment hardcoded a
count ("the trailing five") that a sixth pattern would silently falsify.
Declined: adding a comment that *.bak-* is broad -- the comment two lines
above already spells out the foo.py.bak-pre-x-<stamp> shape it targets.
Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
fix: keep backup files out of preset export archives
`replacement_scripts()` reads configs/presets/_builtin/default-minqlxtended/
scripts/ and nothing else, so a plugin absent from that directory is one the
cross-runtime import dialog cannot offer a replacement for. Importing the
x76-HIGH-PING preset onto a minqlxtended host offered 23 of 61 files and
stripped the other 38 with no recourse; it now offers 53.
Ported from the builtin minqlx default preset, except improved_timer,
kickban, protected_flag, ranked and spec_switch_guard, which exist only in
the X76 preset and were ported from there at the operator's request.
reset_acc.py and suppress_join_msg.py needed no porting -- P3 already did
that -- only somewhere to be seen from, so they move out of BASELINE_ONLY.
These are preset-only rather than vendored into ql-assets/data/. The baseline
is "upstream at the pinned commit plus QLSM's own seven ports", and every file
in it lands in every instance's plugin directory regardless of preset; neither
is true of third-party code QLSM merely carries a port of. highfps.py is the
existing precedent.
Two files in the operator's report are deliberately absent:
- x76admin.py and x76wavechat.py were excluded by request.
- ServerStatus.py is not a plugin. It is an Oracle WebLogic admin script
(cmo.getServers(), ServerLifeCycleRuntimes) written in Python 2 that reads
sys.argv[1:6] at import, dropped into the plugin directory by accident.
Beyond the mechanical import/constant rename, the ports carry:
- handler signatures for the six events whose argument count changed, and
rewrites for game_end/kill/death, which keep their count and change their
meaning (serverBDM threads round_end's round_number through what used to
read data["ROUND"]; kills.py reconstructs SPEED/AIRBORNE/SUBMERGED/TEAMKILL
from Player.velocity and Entity, the ZMQ stats dict being gone).
- Game.team_scores indexed by Team.index, replacing red_score/blue_score.
- minqlxtended.configstring() for the removed get_configstring().
- Command.names where the code intersects a set against a command's aliases.
Command.name is a list on minqlx and the primary name as a str on
minqlxtended, so clanmembers and serverBDM were silently intersecting
against a string's characters inside a bare except.
- property access for position/velocity, which are no longer callables.
Redis keys, author headers and upstream URLs are unchanged throughout.
The draft and preset test constants move with them: the nine files a
minqlxtended-preset -> minqlx-host load used to delete without naming are now
shipped by both default presets, which takes them out of that class entirely.
myFun.py could not load on a minqlxtended server. Its handle_chat took three
arguments; the engine passes four, so add_hook raised at registration and took
the whole plugin down at server start. Shipped in both the baseline and the
default preset since P3.
The bug is a misreading of one line. ChatEventDispatcher.dispatch declares
`recipient=None`, which looks optional -- and is, at the dispatch call site.
It is not optional on the handler: _check_handler_signature takes the
dispatcher's parameter NAMES (_handler_parameters does not filter defaults out)
and calls signature.bind(*[None] * len(expected)). Binding four positionals
onto a three-parameter handler raises TypeError.
Three separate checks should have caught it and none did:
- ui/plugin_compat.py's _EVENT_ARITY listed 7 events. chat and userinfo also
changed and were missing, because upstream's README counts the six events
that came off the ZMQ stats feed and that count was taken as the whole
list. Both are added; chat matters most, being one of the most-hooked
events in the wild.
- tests/stubs/minqlxtended_stub.py recorded chat as 3. Its EVENT_ARITIES is
now generated from every dispatcher class in _events.py -- all 38, not a
hand-picked 23 -- which also stops add_hook raising KeyError on real events
the table simply omitted (vote_called, stats, frame, userinfo).
- the stub's add_hook counted parameters without defaults and demanded exact
equality. That disagreed with the engine in both directions: it refused
handle_chat(self, player, msg, channel, recipient=None), which binds fine.
It now attempts the bind and lets TypeError decide, which is the engine's
whole rule.
test_chat_accepts_the_optional_recipient asserted the bug as intended
behaviour, so it is rewritten rather than adjusted.
vote_started and vote_ended look like they belong in _EVENT_ARITY and must not
be added. minqlx's dispatchers forward a different argument list than they
accept -- VoteEndedDispatcher.dispatch(self, passed) calls super().dispatch(
votes, vote, args, passed) -- so handlers already take 3 and 4 on both
runtimes. A regression test pins that, because adding them on the strength of
their `def dispatch` line would flag every correct handler as stale.
A cross-runtime import left mydiscordbot.py sitting beside an empty discord_extensions/ and failing at load_extension(). Measured on the x76-HIGH-PING preset with every offered replacement accepted: 69 .py files landed, mydiscordbot.py among them, and all four of its helpers plus extras/textart.py were gone. The sequence is: _seed_draft copies the target runtime's default preset in first, the source preset overlays its own minqlx copies on top, and the filter then deletes those as incompatible. For a root-level file that is fine -- _strip_entry offers the target's version and the operator accepts it. A subdirectory file never gets that offer, because isEnableablePluginPath() rejects any path containing a separator, so it reaches no dialog and there is nothing to accept. Before this branch the minqlxtended preset shipped no subdirectories at all, so the gap could not be hit. When the target ships a .py at the same relative path, the filter now writes that file back instead of deleting it. This is not the offer _strip_entry declines to make: that concern is about relocating a subdirectory file into the plugin root, a path change, whereas this restores the target's own file to the path it already occupied -- undoing the overlay, not inventing anything. The restore is logged separately from removals, since the operator was shown neither. Scoped deliberately, with a test on each boundary: a subdirectory file the target does not ship is still stripped, so this cannot decay into "keep incompatible subdirectory files"; and root-level files are untouched, so no plugin is swapped under the operator without the dialog. test_real_default_preset_cross_runtime_filter_matches_the_report modelled one class of legitimate survivor -- byte-identical to the target's copy -- and needs a second. A restored file is matched on path, not digest: its content differs from the target's by definition, which is why the filter reached it.
…hing An audit of every minqlxtended plugin in the repo turned up three more instances of the bug commit 9cb9d19 fixed in clanmembers and serverBDM. Command.name is a list of every alias on minqlx and the primary name as a str on minqlxtended, with the full list moved to Command.names. Intersecting a set against the str iterates its characters, so the match can never succeed: {"sound"}.intersection(["sound"]) -> {"sound"} # minqlx {"sound"}.intersection("sound") -> set() # minqlxtended Every one of these guards a conflicting-command cleanup inside a bare except, so the failure is silent: the plugin loads, reports nothing, and quietly stops unloading the command it is supposed to displace. - myFun.py, in the baseline and the preset copy alike. This one predates the branch -- it shipped with the P3 port and has never worked on minqlxtended. - mapLimiter.py and restartserver.py, both ported on this branch. Two of the nine port agents found this class of bug in their own files and flagged it; the agent that took these two did not. remove_command() is left alone: it documents that it accepts any one of a command's names, so passing the primary name still resolves. Found by auditing against the review skill's removed-API list rather than by the scanner, which cannot see this -- handler.commands is dynamic attribute access, and the attribute still exists, it just means something else now.
…sses The subdirectory restore in 021f155 fixed the disk and left the dialog behind. A helper the target ships at the same path is put back automatically, but the compatibility report still listed it in `stripped` with `replacement: null` -- which the dialog renders exactly like a file that is genuinely gone: warning triangle, "won't be installed", no checkbox. Operators read the five discord_extensions/ and extras/ helpers as still incompatible while they were in fact landing correctly. That is the same class of defect the gate rework exists to eliminate: the report and the disk disagreeing about what happens. The disk was right this time and the report was wrong, which is the less dangerous direction and still not acceptable. Entries now carry `auto_replaced`, set for a subdirectory file the target's default preset ships at that exact path, and the dialog gives those their own treatment -- swapped, not lost, with no checkbox because there is no decision to make. The summary above the list said flatly that the plugins below would not be installed; it now distinguishes the three outcomes it actually covers (offered a swap, swapped automatically, dropped). Both sides read the shipped set through one function. preset_compat gains shipped_scripts(), which walks subdirectories where replacement_scripts() deliberately stays flat, and draft_routes._target_default_preset_files() delegates to it rather than keeping its own copy of the same walk. The report and the filter disagreeing about which files the target ships is precisely how this gate has gone wrong twice before. ServerStatus.py is deliberately unaffected and still reports as an unrecoverable loss, which is accurate: it is a Python 2 WebLogic admin script, there is no counterpart to restore, and it does not land on disk.
Ports 34 third-party plugins so the cross-runtime import gate has something to
offer for them. Importing the x76-HIGH-PING preset onto a minqlxtended host
went from offering 23 of 61 files to offering 53.
Also fixes what the port work exposed:
- myFun.py could not load on minqlxtended at all -- a three-argument chat
handler against a four-argument dispatch -- along with the three checks
that should have caught it and did not (the scanner's arity table listed
7 of the changed events, the test stub recorded chat as 3, and the stub's
add_hook counted parameters instead of attempting the bind).
- subdirectory helpers were deleted with no offer and no recourse, so
mydiscordbot.py landed beside an empty discord_extensions/. The target's
own copy is now restored, and the dialog says so rather than reporting a
loss that does not happen.
- five Command.name intersections across five plugins, silently matching
nothing since Command.name became a str.
--no-ff so the phase stays legible in the history, as the earlier phases do.
An external review of all 42 ported plugins raised ~40 findings. All six it rated Critical are byte-identical to code already shipping in the minqlx preset, so the port did not introduce them and they are out of scope here. What did need fixing is the class the review mostly missed: code that worked on the old hosts and breaks on the new runtime. kickban.py — the port deleted this repo's zadd_compat/hset_compat shims and left the redis-py 2.x positional zadd behind, while modernising hset to mapping= in the same file. minqlxtended ships redis-py 5.1+ (8.x observed live), where zadd's second positional is the mapping: verified against redis-py 6.2.0 it raises AttributeError: 'float' object has no attribute 'items'. Kick recording and ban creation both failed outright. Switched to the mapping form used by ban.py, silence.py and leaverban.py in the same preset, rather than reinstating a shim for a redis-py this runtime cannot have. irc.py — @asyncio.coroutine was removed in Python 3.11 and the target is Ubuntu 24.04 / Python 3.12.3. It decorates two methods in a class body, so this was an import-time AttributeError: irc.py did not misbehave, it failed to load. Converted to async def / await. Not in the review. mybalance.py — Thread.isAlive() was removed in Python 3.9. The review filed this as an obsolete-API nit; on 3.12 it is an AttributeError. serverchecker.py — the update thread had no unload handling, so every !reload left the previous one polling forever and added another status writer. Added the unload hook the sibling plugins already use. A full sweep for the rest of this class (removed threading/asyncio/stdlib APIs, and every redis-py signature that changed in 3.x) turns up nothing further. Note ui/plugin_compat.py checks minqlx<->minqlxtended symbols and event arities only, which is why the compatibility scan was clean while these three sat in the tree. Claude-Session: https://claude.ai/code/session_0129eHB2egUzqXXnpJRDmGEo
Minor bump: minqlxtended becomes a selectable server runtime. The migration plan defers this bump to the single PR to main so the changelog entry can cite its number, which is #193. Claude-Session: https://claude.ai/code/session_0129eHB2egUzqXXnpJRDmGEo
There was a problem hiding this comment.
PR Review: minqlxtended Runtime Support
Strengths
ui/runtime.pysingle source of truth (ui/runtime.py): Every hardcoded path, filename, and launch script for both runtimes lives in one table. Future runtime additions are trivial and drift-proof.- Compatibility gate design (
ui/plugin_compat.py): The "prove incompatible OR hash-match; everything else is UNKNOWN and stripped" posture is the right conservative choice for a deployment tool. Thecode_onlymasking rationale, the_scan_paramslambda-depth comment, and theapply_compatibilityreturn-identity guarantee are all load-bearing documentation. - Database migrations (
migrations/versions/20260818000000_add_runtime_to_host.py,...config_preset.py): Both migrations useserver_default='minqlx'to backfill existing rows cleanly. The downgrade path refuses if non-minqlx rows exist, preventing silent data loss. - Backward compatibility:
normalize_runtime(None)→'minqlx'means old backups and old API callers all land correctly on the existing runtime without schema-version negotiation. effective_lan_rate()/ column separation (ui/lan_rate_policy.py): The storedlan_rate_enabledcolumn stays honest for presets; the minqlxtended 99k policy is injected at query time. Prevents policy bleed across runtimes.- Test coverage (~30 new test files): Tests hit the actual ORM, Flask test client, and Ansible extravars merge — not just internal function signatures. The minqlxtended plugin baseline and Python-preflight edge-case tests are particularly solid.
- Pinned upstream commit: minqlxtended is cloned at a specific commit hash, not a branch, preventing silent upstream drift.
Issues
Critical (Must Fix)
None.
Important (Should Fix)
1. dpca.factories and ifrj.factories are bare JSON objects, not arrays
configs/presets/_builtin/default-minqlxtended/factories/dpca.factoriesline 1configs/presets/_builtin/default-minqlxtended/factories/ifrj.factoriesline 1- Both files contain
{ ... }at the top level. All other factory files in the preset use[{ ... }](a single-element array). If the factory loader expects an array (as the rest of the codebase implies), these two configs will fail to load — either silently returning an empty list or raising a deserialization error. Wrap each in[ ].
2. Host.runtime immutability enforced only by convention
ui/models.py(Host class) /migrations/versions/20260818000000_add_runtime_to_host.py- The design doc and comments correctly state that
runtimemust never be changed after the host is provisioned (a change would put minqlx binaries on a host expecting minqlxtended and vice versa). However, nothing at the DB or ORM layer enforces this: noCHECKconstraint, no trigger, no SQLAlchemyvalidatesdecorator. A future API endpoint, an admin SQL command, or a refactor could silently change it. - Add a DB-level
CHECK (runtime IN ('minqlx', 'minqlxtended'))in the migration and a SQLAlchemy@validates('runtime')that raises on any attempt to change a non-null value.
Minor (Nice to Have)
3. Duplicate "g_splashradius_pg" key in moreGametypes.factories
configs/presets/_builtin/default-minqlxtended/factories/moreGametypes.factorieslines 2863–2864- The same key appears twice in one JSON object. Most parsers silently keep the last value, so behavior is correct, but it is a data authoring mistake that will confuse future editors. Remove the duplicate.
4. minqlxtended build steps duplicated across two playbooks
ansible/playbooks/setup_host.yml(minqlxtended build section)ansible/playbooks/rebuild_minqlx.yml(entire file)- The task sequence (clean dir → ensure shared dir → clone → make → copy artifacts) is reproduced verbatim in both playbooks. Any build-step change (new artifact, different make target) must be applied in both. Extract into an
include_tasksfile.
5. Missing trailing newlines in six factory files
12rg.factories,ROFO.factories,dpca.factories,hoqlctf.factories,ifrj.factories,norghmg.factories— all have\ No newline at end of file. Not a runtime issue but violates POSIX text file conventions and causes noisy diffs.
6. .split()[-1] on a single-word version string
ui/standalone_ssh.pyline ~61793 inside_detect_remote_python_versionplatform.python_version()always outputs a bareX.Y.Zwith no spaces..split()[-1]on it returns the whole string, so the call is correct but the idiom is misleading — a direct.strip()communicates intent better.
7. Known remaining hardcode in script_routes.py
ui/routes/script_routes.py—TODO(runtime-split)comment- The script-browser endpoint still hardcodes
'default'(the minqlx preset name). The comment correctly flags this as a dead/browsing-only path with no caller in the React app. Fine to leave deferred, but should have a tracking issue so it is not forgotten.
Assessment
Ready to merge? Yes, with fixes for issues 1 and 2.
Reasoning: The runtime abstraction is well-engineered and thoroughly tested. Issues 1 and 2 carry real production risk — two factory configs that may fail to load and a structural invariant that isn't enforced — but both are contained and fixable without architectural rework. Everything else is cleanup.
The upstream engine's WEAPONS-kind fields (shots_fired, shots_hit, and others) gained a real Python setter in minqlxtended v1.0.2 (tjone270/minqlxtended@97fbe671), following tjone270/minqlxtended#6. reset_acc.py now assigns minqlxtended.NO_AMMO directly instead of relying on the getattr-guarded local engine patch this branch previously carried; the known limitation noted for reset_acc.py since P3 no longer applies. Bumps the pinned minqlxtended commit (ui/runtime.py, both ansible playbooks) from 1e2f307 to 97fbe671 so provisioned hosts actually get the fix. Updates the plugin manifest, the synced default-minqlxtended preset copy, the test stub, and existing reset_acc tests to match. Claude-Session: https://claude.ai/code/session_0129eHB2egUzqXXnpJRDmGEo
…fps blocker minqlxtended detours SV_Shutdown before highfps loads, so the live-memory pattern scan highfps used to locate svs always missed and init_hook() bailed at -2 — the "highfps_hook.so is inert on minqlxtended" blocker recorded in docs/findings/2026-08-21-minqlxtended-live-validation-findings.md. Fixed upstream in dngrtech/qlsm_plugins@342da45: the SV_Shutdown lookup now pattern-matches the on-disk qzeroded binary instead of its (patchable) live mapping, using the constant (address, file-offset) delta a non-PIE binary gives you per mapped segment. SV_ClientThink, which highfps itself detours, is untouched by minqlxtended and still uses the live-memory scan. Re-copied the rebuilt, byte-identical highfps_hook.so into both builtin presets and updated the pinned sha256 in test_default_minqlxtended_preset.py. Full suite: 1958 passed.
Adds minqlxtended (
github.com/tjone270/minqlxtended) as a second, per-host server runtime alongside minqlx. 110 commits, phases P0–P7, each previously reviewed and merged into this integration branch.minqlx and minqlxtended are a hard fork of each other — plugins written for one do not run on the other — so the runtime is chosen at host creation and is immutable thereafter. minqlx remains the default (no runtime pre-selected in the UI as of P6 — see below). No existing minqlx host's behaviour changes.
What this delivers
Host.runtimecolumn, provisioning, Ubuntu 24.04servercheckerqlsm_pluginsnative hook port (highfps), exposed a stale minqlx-side buildsv_serverTypeunder forced 99kEach phase below was independently reviewed and merged before the next started; this section summarizes what actually shipped and what each review caught, not just the one-line scope.
P1 — runtime plumbing (host schema, provisioning, instances, presets)
ui/runtime.pyis the single source of truth for every per-runtime value — plugins dirname, shared dir, engine.so, launch script, log filename, git repo/pin, OS name/family, Python floor, excluded system hooks.Host.runtime/ConfigPreset.runtimecolumns,NOT NULLwithserver_default='minqlx'so existing rows backfill, and immutable once a host exists.setup_host.ymlbranches on it to clone, build and patch the right runtime.LD_PRELOADcomposition, the systemd launch script and log filenames all resolve per runtime.LD_PRELOADcomposition is unchanged because minqlx's excluded-hooks set is empty, and Terraform resolves the sameos_idfor a minqlx host (no resource churn).Host.runtimewhile a non-minqlx row exists) is load-bearing: without it, a routineterraform applyafter a schema downgrade/re-upgrade would silently resolve Debian'sos_idfor a host physically running Ubuntu 24.04, and Vultr would reinstall the OS under a live game server.P2 — plugin baseline + vendoring
P1 wired the plumbing but vendored no content, so a minqlxtended host provisioned and then sat there with zero plugins. This is what made one actually usable.
ql-assets/data/minqlxtended-plugins/, pinned totjone270/minqlxtended-plugins@d93a3ce, all verified byte-identical to upstream.manifest.jsontrackssha256+originper file so a re-vendor can't silently reclassify QLSM-owned code as upstream.serverchecker.pyported — a hard dependency of live status (writes theminqlx:server_status:<port>Redis key the poller reads), prepended to every instance's plugin list rather than optional.default-minqlxtendedbuiltin preset added, mirroring the minqlx default's checked plugins.Sys_IsLANAddressto return true for every client, sosv_lanForceRateis the only remaining control, and QLSM pins it to1. This went through a revert/rebuild cycle (80bea42→f1c7d3f) after an earlier version claimed the toggle was engine-inert while the backend still emitted0, contradicting itself.NonexistentGameErrorpast the score guard, skipping the Redis write entirely — the exact failure mode that makes a healthy server look dead; andPOST /api/instanceswith nodraft_idseeded a minqlxtended host from the minqlx default preset, so every user plugin died onimport minqlxwith nothing in the UI suggesting why.ob(Obelisk) tool(Overload), so the live-status modals — which only knowob— hide team scores for an Overload match.P3 / P3.1 — the six QLSM plugin ports
Ported
myFun,suppress_join_msg,specqueue,reset_acc,player_infoandcommands, completing the QLSM-owned half of the migration.RET_*/PRI_*constants don't exist on minqlxtended (45 call sites moved toReturn.*/Priority.*); six handler signatures changed, the dangerous one beingkill/death— same arity, but the third argument became the means of death instead of a stats dict, so a lazy port registers fine and only raises on the first kill;Game.red_score/blue_scoreare gone, replaced byteam_scoresindexed byTeam.index.reset_acccould not fully replace its C-side patch at the time: the per-weaponshotsFired/shotsHitarrays were read-only on minqlxtended (QLX_SETTER_WEAPONSwasNULL), so aggregate accuracy reset but the end-of-match weapon breakdown didn't — the plugin's own message was corrected to stop claiming otherwise. Fixed later on this branch (715f754, see below) once minqlxtended v1.0.2 added a real setter for those fields.player_infohad an undeclared dependency oniouonegirl.py's self-installing base class, which downloads from a minqlx plugin repo on import. Carrying that across would have put a minqlx auto-updater on a minqlxtended host; the port inlines the one method it needed instead.configs/presets/_builtin/default/scripts/specqueue.py's AFK sweep is dedented out of itselif, moving every player to spectator on every pass. A test pins the correct nesting on the minqlxtended side so this branch can't inherit it.main(8f4c8e3) onto the ports taken before that fix existed:myFun.py's unsanitised!soundargument reachingconsole_command("fdir {}")(console-command injection via;, moderator-scope), andplayer_info.py's unboundedrequests.get()blocking a worker thread forever against an unreachable endpoint.P4 — highfps native hook
Ported
highfps.py(the one ofqlsm_plugins' three native-hook plugins the minqlx default preset already ships) intodefault-minqlxtended. The.soitself is runtime-agnostic — it detoursSV_ClientThinkinsideqzerodedand knows nothing about the Python side — so only the Python module needed porting; the same build is shipped on both presets.Porting it exposed a stale build on the minqlx side: QLSM's minqlx preset copy predated three upstream review fixes (missing
!highfpsbaseline guard, causing false cheat accusations against players the sampler hadn't seen yet; an unclamped sample-interval cvar that could divide by zero; a missing padding cvar that fired detection exactly at the threshold instead of above it). Fixed onmaindirectly (971c5dd) since it was a minqlx-side bug, and carried onto this branch so it doesn't regress on merge. Tests now pin the two.pycopies to differ by only the runtime rename, and the.socopies by byte-identity, so this class of drift fails CI instead of waiting for someone to notice.P5 / P5.1 — cross-runtime preset compatibility gate
Loading a preset saved on one runtime onto a host running the other is allowed but lossy, since the plugin APIs are incompatible. This gate strips what can't run on the target, tells the operator what it dropped, and offers the target's own version where one exists.
This was a rework, not the first attempt — the original P5 enforced the gate on a response field no frontend code reads, so the dialog promised removals that never touched disk, under 550 green tests. Enforcement now lives in
_seed_draft, where the files actually are.Three Critical over-strip bugs surfaced across the two attempts, all found by review, none by tests: the original's dead-field enforcement; a same-runtime load that deleted 13 of 53 plugins because the filter ran without comparing source vs. target; and a final-review catch where
_seed_draftfiltered the target runtime's own default overlay, deleting 32 files, 9 of which were never even shown to the operator. Root cause: the test fixture never populated builtin preset rows, so the code path being tested was dead in every test in the repo — fixed with a new fixture and a note inconftest.pyfor the next person.ui/plugin_compat.pyscans each plugin file intocompatible(sha256 match against a shipped manifest) /incompatible(removed API found) /unknown(neither).sv_serverTypeon minqlxtended (1under forced 99k,2otherwise), companion to the main-side fix in fix: restore sv_serverType to the qlds launch args (99k LAN rate regression) #187, keyed to the effective forced-on policy rather than the raw (usually-False, UI-disabled) stored column — a naive port of the main-side fix would have pairedsv_serverType 2withsv_lanForceRate 1, the exact broken combination the fix exists to remove.P6 — explicit runtime choice + docs
Replaces the originally planned "flip the default to minqlxtended" with no default at all: the Add Host runtime picker now requires an explicit choice, since the runtime is immutable once the host exists and pre-selecting either one lets an operator who never looked at the field make an irreversible decision. Each option's tooltip is provider-aware (cloud provisions the OS; standalone/self run the distro's own Python, so only minqlxtended has a version floor to describe, and only standalone checks it up front).
Docs PR (#190) covered four pages the earlier phases had missed and that would actively mislead a minqlxtended operator: log filename naming, why
force_rate.sois correctly absent on minqlxtended (loading it alongside the runtime's own patch would crash the server at startup), the Python-floor troubleshooting entry (worst on self hosts, which have no pre-check and land in Error if unmet), and the getting-started feature list.P7 — third-party plugin ports (merged directly to this branch, no standalone PR)
Ported 34 more plugins so the cross-runtime import gate has something to offer for them — importing the
x76-HIGH-PINGpreset onto a minqlxtended host went from offering 23 of 61 files to 53. Two files were excluded by request (x76admin.py,x76wavechat.py);ServerStatus.pywas excluded because it isn't a plugin (a Python 2 Oracle WebLogic admin script that happened to be sitting in the plugin directory).The port work surfaced three more defect classes, fixed in the same phase:
myFun.pycouldn't load on minqlxtended at all — itshandle_chattook three arguments against the engine's four, soadd_hookraised at registration and took the whole plugin down at server start. This had shipped broken since P3. Three separate checks should have caught it and didn't: the compat scanner's arity table was missingchatanduserinfo(regenerated from all 38 real dispatcher classes instead of a hand-picked subset); the test stub hadchatrecorded as arity 3; and the stub'sadd_hookcompared parameter counts instead of attempting the actual bind.Command.nameintersected against a string instead of a list in five plugins (clanmembers,serverBDM,myFun,mapLimiter,restartserver) —Command.nameis a list of aliases on minqlx but the primary name as a barestron minqlxtended, so{"sound"}.intersection("sound")silently matches nothing instead of raising. Every instance sat inside a bareexcept, so the plugins loaded and simply never unloaded the command they were meant to displace. Fixed in two passes (9cb9d19, then a full-repo audit in73b30bdcaught three more).mydiscordbot.pylanded next to an emptydiscord_extensions/because the import dialog's replacement offer only covers root-level files. Fixed by writing the target runtime's own copy back automatically when one exists at that path (021f155), then a follow-up (b5e9aa2) because the compatibility report still listed those files as unrecoverable losses after the disk was already correct — operators were seeing red "won't be installed" warnings for files that had, in fact, landed fine.The last four commits on the branch
4ac7186— four runtime breaks the ports carried onto Python 3.12 / redis-py 5. An external review covered all 42 ported plugins and raised ~40 findings. Assessed against the pre-port copies inconfigs/presets/_builtin/default/scripts/, all six findings it rated Critical are byte-identical to code already shipping in the minqlx preset (protect.py's!setpassconsole interpolation,mybalance.py'seval()and GitHub bootstrap-download,iouonegirl.py's self-install, thesupersecretDiscord default) — the port renamed APIs, it didn't write any of that, and rewriting vendored third-party security posture inside a port is a separate project that would destroy the "identical except for the API rename" property that made the port reviewable. What the review largely missed is the class of thing that worked on the old hosts and breaks on the new runtime:kickban.py— the port dropped this repo'szadd_compat/hset_compatshims and left a redis-py 2.x positionalzaddcall behind. Verified against redis-py 6.2.0:AttributeError: 'float' object has no attribute 'items'. Kick recording and ban creation both failed outright.irc.py—@asyncio.coroutine, removed in Python 3.11, decorating two methods. An import-timeAttributeError— the plugin didn't misbehave, it failed to load. Not in the review.mybalance.py—Thread.isAlive(), removed in Python 3.9. The review filed this as an obsolete-API nit; on 3.12 it's a hardAttributeError.serverchecker.py— the update thread had no unload handling, so every!reloadleft the previous one polling forever and added another status writer.A full sweep for the rest of this class (removed threading/asyncio/stdlib APIs, every redis-py signature that changed in 3.x) turned up nothing further.
ui/plugin_compat.pyonly checks minqlx↔minqlxtended symbols and event arities, which is why the compatibility scan stayed clean while these sat in the tree.36bb157— version bump to v1.28.0, per the branch strategy of bumping on the single PR tomainso the changelog entry can cite this PR's number.715f754— reset_acc resets per-weapon accuracy too, closing the P3 gap above. The per-weaponshotsFired/shotsHitarrays were read-only on minqlxtended becauseWEAPONS-kind fields had no Python setter at all. Opened tjone270/minqlxtended#6 proposing a narrow, zero-only engine patch; the maintainer instead shipped a more general fix upstream as v1.0.2 (97fbe671) — everyWEAPONS-kind field gained a real setter, soexpanded_stats.shots_fired = minqlxtended.NO_AMMOnow works directly, no local patch needed. This commit switchesreset_acc.pyto that call, bumps the pinned minqlxtended commit (ui/runtime.py, both ansible playbooks) from1e2f307to97fbe671so provisioned hosts actually get the fix, and updates the plugin manifest, the synceddefault-minqlxtendedpreset copy, and the existing reset_acc tests/stub to assert the per-weapon reset instead of its absence. Verified live against a real running QLDS instance before opening the upstream PR: bot-accumulated lightning-gun stats (shots_fired=6959, shots_hit=1896) zeroed correctly through the same code path.aff1b87— fixes the highfps blocker below by re-vendoring a correctedhighfps_hook.so. minqlxtended detoursSV_Shutdownbefore highfps loads, which is what made the live-memory pattern scan insearch_module()always miss andinit_hook()bail at-2. Fixed upstream indngrtech/qlsm_plugins@342da45: theSV_Shutdownlookup now pattern-matches the on-diskqzerodedbinary instead of its live (patchable) mapping —qzerodedis non-PIE, so a mapped segment's (runtime address, file offset) delta is constant and recoverable from/proc/self/maps, which makes the lookup immune to any detour installed over the live bytes.SV_ClientThink, the function highfps itself detours, is untouched by minqlxtended and still uses the live-memory scan. Verified with a synthetic harness that detours a test function's live bytes the same way and confirms the file-based lookup still resolves the correct address; re-copied the rebuilt, byte-identical.sointo both builtin presets and updated the pinned sha256 intest_default_minqlxtended_preset.py. Full suite re-run clean: 1958 passed. Not yet re-validated against a live minqlxtended host — the original finding was caught by live A/B testing, and this fix has only been proven at the pattern/address-math level locally; closing the loop needs a redeploy to a minqlxtended host running highfps.Known blockers and limitations
🟢
Fixed inhighfps_hook.sois inert on minqlxtended.aff1b87above, pending live re-validation (see that commit's note).This branch has not had real playtime on a live minqlxtended host. Both validation hosts from the 2026-08-21 A/B session (write-up:
docs/findings/2026-08-21-minqlxtended-live-validation-findings.md, local/untracked per this repo's docs convention) were left running for that criterion, but it remains open.Other known, accepted costs from the phases above:
ob→ol(Obelisk→Overload) rename means live-status team scores hide for Overload matches on minqlxtended.draft_routes.py(the compatibility gate's enforcement point) is now ~817 lines against the repo's 300/500-line guideline; splitting it was deliberately left for its own PR to avoid burying the P5 Critical fixes in unrelated churn.Verification
715f754and again afteraff1b87— same count both times, since neither commit adds new test functions.pnpm lint/eslintclean throughout. See the linked phase PRs for the exact numbers each one verified.serverchecker) for the first time; the cross-runtime preset filter exercised by hand and measured on disk against its own report; the P6 runtime picker verified in the running app;force_rate.soconfirmed to correctly never load.ui/plugin_compat.pychecks minqlx↔minqlxtended symbols and event arities only — it does not check Python-version or redis-py-version compatibility, which is why the compat scan stayed green while the four runtime breaks in4ac7186sat in the tree.tests/test_kickban_plugin.pystill exercises the legacyql-assets/data/minqlx-pluginscopy, not the ported minqlxtended one — the gap that let thezaddregression ship. The ported copy remains uncovered by an executing test; recommended follow-up below.Follow-ups worth doing, not blocking this PR
ui/plugin_compat.pywith a small deny-list (isAlive,@asyncio.coroutine, positionalzadd) so the class of bug in4ac7186shows up in the scan instead of only in manual review.kickban.pyunder a real redis-py version, not just the legacy copy.\Z-vs-$regex fix frommyFun.py's P3.1 sanitiser back to the two minqlx-side copies, so both runtimes read identically (harmless today; command arguments arrive whitespace-split).draft_routes.py(~817 lines) into smaller modules.specqueue.pyAFK-sweep indentation bug found during P3 (not part of this branch's scope; needs its own PR againstmain).