Skip to content

feat: minqlxtended as a selectable server runtime - #193

Open
dngrtech wants to merge 112 commits into
mainfrom
feature/minqlxtended
Open

feat: minqlxtended as a selectable server runtime#193
dngrtech wants to merge 112 commits into
mainfrom
feature/minqlxtended

Conversation

@dngrtech

@dngrtech dngrtech commented Aug 22, 2026

Copy link
Copy Markdown
Owner

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.

Base is feature/minqlxtended, not main. Every phase PR based onto this branch rather than main; this is the one PR that merges the whole migration.

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

Phase Scope PR
P1 Runtime plumbing — Host.runtime column, provisioning, Ubuntu 24.04 #180
P2 Plugin baseline + vendoring, ported serverchecker #181
P3 / P3.1 The six QLSM plugin ports, plus main-side carryover fixes #182, #183
P4 qlsm_plugins native hook port (highfps), exposed a stale minqlx-side build #184
P5 / P5.1 Cross-runtime preset compatibility gate; sv_serverType under forced 99k #186, #188
P6 Explicit runtime choice in Add Host, per-runtime tooltips, user docs #189, #190
P7 Third-party plugin ports (34 plugins, 42 total ported across the branch) merged directly, no PR number

Each 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.py is 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.runtime columns, NOT NULL with server_default='minqlx' so existing rows backfill, and immutable once a host exists.
  • Terraform picks the OS image from the runtime (Debian 12 for minqlx, Ubuntu 24.04 for minqlxtended); setup_host.yml branches on it to clone, build and patch the right runtime.
  • Instance plugin paths, LD_PRELOAD composition, the systemd launch script and log filenames all resolve per runtime.
  • minqlxtended requires Python 3.12+: standalone hosts are rejected synchronously at creation; self hosts are gated by the setup playbook instead, since that's the only point they can be checked.
  • Existing minqlx hosts are provably untouched — verified, not assumed: the rendered systemd unit is byte-identical trailing space included, LD_PRELOAD composition is unchanged because minqlx's excluded-hooks set is empty, and Terraform resolves the same os_id for a minqlx host (no resource churn).
  • The downgrade guard (refusing to drop Host.runtime while a non-minqlx row exists) is load-bearing: without it, a routine terraform apply after a schema downgrade/re-upgrade would silently resolve Debian's os_id for 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.

  • 33 upstream plugins vendored into ql-assets/data/minqlxtended-plugins/, pinned to tjone270/minqlxtended-plugins@d93a3ce, all verified byte-identical to upstream. manifest.json tracks sha256 + origin per file so a re-vendor can't silently reclassify QLSM-owned code as upstream.
  • serverchecker.py ported — a hard dependency of live status (writes the minqlx:server_status:<port> Redis key the poller reads), prepended to every instance's plugin list rather than optional.
  • default-minqlxtended builtin preset added, mirroring the minqlx default's checked plugins.
  • 99k LAN Rate fixed on, by product decision: minqlxtended hooks Sys_IsLANAddress to return true for every client, so sv_lanForceRate is the only remaining control, and QLSM pins it to 1. This went through a revert/rebuild cycle (80bea42f1c7d3f) after an earlier version claimed the toggle was engine-inert while the backend still emitted 0, contradicting itself.
  • Review caught two live-status bugs before merge: a game vanishing mid-cycle raised NonexistentGameError past the score guard, skipping the Redis write entirely — the exact failure mode that makes a healthy server look dead; and POST /api/instances with no draft_id seeded a minqlxtended host from the minqlx default preset, so every user plugin died on import minqlx with nothing in the UI suggesting why.
  • Known, deliberately deferred: minqlxtended renamed minqlx's ob (Obelisk) to ol (Overload), so the live-status modals — which only know ob — hide team scores for an Overload match.
P3 / P3.1 — the six QLSM plugin ports

Ported myFun, suppress_join_msg, specqueue, reset_acc, player_info and commands, completing the QLSM-owned half of the migration.

  • Three API deltas drove most of the work: RET_*/PRI_* constants don't exist on minqlxtended (45 call sites moved to Return.*/Priority.*); six handler signatures changed, the dangerous one being kill/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_score are gone, replaced by team_scores indexed by Team.index.
  • reset_acc could not fully replace its C-side patch at the time: the per-weapon shotsFired/shotsHit arrays were read-only on minqlxtended (QLX_SETTER_WEAPONS was NULL), 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_info had an undeclared dependency on iouonegirl.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.
  • Found, but explicitly not fixed here (minqlx-side, out of scope for this branch): configs/presets/_builtin/default/scripts/specqueue.py's AFK sweep is dedented out of its elif, 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.
  • P3.1 (fix: carry the two main-side plugin fixes onto the minqlxtended ports #183) carried two plugin-bug fixes from main (8f4c8e3) onto the ports taken before that fix existed: myFun.py's unsanitised !sound argument reaching console_command("fdir {}") (console-command injection via ;, moderator-scope), and player_info.py's unbounded requests.get() blocking a worker thread forever against an unreachable endpoint.
P4 — highfps native hook

Ported highfps.py (the one of qlsm_plugins' three native-hook plugins the minqlx default preset already ships) into default-minqlxtended. The .so itself is runtime-agnostic — it detours SV_ClientThink inside qzeroded and 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 !highfps baseline 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 on main directly (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 .py copies to differ by only the runtime rename, and the .so copies 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_draft filtered 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 in conftest.py for the next person.

  • ui/plugin_compat.py scans each plugin file into compatible (sha256 match against a shipped manifest) / incompatible (removed API found) / unknown (neither).
  • Cross-runtime loads cost 30 of 53 plugins outright (23 have a same-named replacement offered) — stated here as the honest cost of two diverging APIs, not a defect.
  • P5.1 (fix: restore sv_serverType on minqlxtended, keyed to the forced 99k policy #188) restored sv_serverType on minqlxtended (1 under forced 99k, 2 otherwise), 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 paired sv_serverType 2 with sv_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.so is 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-PING preset onto a minqlxtended host went from offering 23 of 61 files to 53. Two files were excluded by request (x76admin.py, x76wavechat.py); ServerStatus.py was 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.py couldn't load on minqlxtended at all — its handle_chat took three arguments against the engine's four, so add_hook raised 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 missing chat and userinfo (regenerated from all 38 real dispatcher classes instead of a hand-picked subset); the test stub had chat recorded as arity 3; and the stub's add_hook compared parameter counts instead of attempting the actual bind.
  • Command.name intersected against a string instead of a list in five plugins (clanmembers, serverBDM, myFun, mapLimiter, restartserver) — Command.name is a list of aliases on minqlx but the primary name as a bare str on minqlxtended, so {"sound"}.intersection("sound") silently matches nothing instead of raising. Every instance sat inside a bare except, 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 in 73b30bd caught three more).
  • Subdirectory plugin helpers were deleted with no offer and no recourse during a cross-runtime import — mydiscordbot.py landed next to an empty discord_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 in configs/presets/_builtin/default/scripts/, all six findings it rated Critical are byte-identical to code already shipping in the minqlx preset (protect.py's !setpass console interpolation, mybalance.py's eval() and GitHub bootstrap-download, iouonegirl.py's self-install, the supersecret Discord 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:

  1. kickban.py — the port dropped this repo's zadd_compat/hset_compat shims and left a redis-py 2.x positional zadd call behind. Verified against redis-py 6.2.0: AttributeError: 'float' object has no attribute 'items'. Kick recording and ban creation both failed outright.
  2. irc.py@asyncio.coroutine, removed in Python 3.11, decorating two methods. An import-time AttributeError — the plugin didn't misbehave, it failed to load. Not in the review.
  3. mybalance.pyThread.isAlive(), removed in Python 3.9. The review filed this as an obsolete-API nit; on 3.12 it's a hard AttributeError.
  4. serverchecker.py — the update thread had no unload handling, so every !reload left 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.py only 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 to main so the changelog entry can cite this PR's number.

715f754 — reset_acc resets per-weapon accuracy too, closing the P3 gap above. The per-weapon shotsFired/shotsHit arrays were read-only on minqlxtended because WEAPONS-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) — every WEAPONS-kind field gained a real setter, so expanded_stats.shots_fired = minqlxtended.NO_AMMO now works directly, no local patch needed. This commit switches reset_acc.py to that call, bumps the pinned minqlxtended commit (ui/runtime.py, both ansible playbooks) from 1e2f307 to 97fbe671 so provisioned hosts actually get the fix, and updates the plugin manifest, the synced default-minqlxtended preset 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 corrected highfps_hook.so. minqlxtended detours SV_Shutdown before highfps loads, which is what made the live-memory pattern scan in search_module() always miss and init_hook() bail at -2. Fixed upstream in dngrtech/qlsm_plugins@342da45: the SV_Shutdown lookup now pattern-matches the on-disk qzeroded binary instead of its live (patchable) mapping — qzeroded is 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 .so into both builtin presets and updated the pinned sha256 in test_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

🟢 highfps_hook.so is inert on minqlxtended. Fixed in aff1b87 above, 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:

  • Cross-runtime preset loads lose 30 of 53 plugins outright (23 have replacements) — an accepted product cost of two diverging APIs, not a bug.
  • obol (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

  • Full backend suite: 1958 passed, 0 failed (2 new tests cover the serverchecker unload path added in the final fix). Re-run clean after 715f754 and again after aff1b87 — same count both times, since neither commit adds new test functions.
  • All 42 ported plugins byte-compile; plugin manifest regenerated and hash-verified.
  • Each phase above carried its own test suite before merging into this branch — phase counts ranged from 108 (P3) to 767 (P5.1) passing across the touched suites, plus full frontend runs (648–733 passed per phase) and pnpm lint/eslint clean throughout. See the linked phase PRs for the exact numbers each one verified.
  • Live A/B validation on two purpose-built minqlxtended hosts (2026-08-21): plugins observed loading for real (12, including 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.so confirmed to correctly never load.
  • ui/plugin_compat.py checks 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 in 4ac7186 sat in the tree.
  • tests/test_kickban_plugin.py still exercises the legacy ql-assets/data/minqlx-plugins copy, not the ported minqlxtended one — the gap that let the zadd regression ship. The ported copy remains uncovered by an executing test; recommended follow-up below.

Follow-ups worth doing, not blocking this PR

  • Extend ui/plugin_compat.py with a small deny-list (isAlive, @asyncio.coroutine, positional zadd) so the class of bug in 4ac7186 shows up in the scan instead of only in manual review.
  • Add a test that actually imports/exercises the ported kickban.py under a real redis-py version, not just the legacy copy.
  • Sync the one-character \Z-vs-$ regex fix from myFun.py's P3.1 sanitiser back to the two minqlx-side copies, so both runtimes read identically (harmless today; command arguments arrive whitespace-split).
  • Split draft_routes.py (~817 lines) into smaller modules.
  • Fix the pre-existing minqlx-side specqueue.py AFK-sweep indentation bug found during P3 (not part of this branch's scope; needs its own PR against main).

rage and others added 30 commits August 18, 2026 23:12
…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
rage and others added 27 commits August 20, 2026 09:53
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
…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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: minqlxtended Runtime Support

Strengths

  • ui/runtime.py single 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. The code_only masking rationale, the _scan_params lambda-depth comment, and the apply_compatibility return-identity guarantee are all load-bearing documentation.
  • Database migrations (migrations/versions/20260818000000_add_runtime_to_host.py, ...config_preset.py): Both migrations use server_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 stored lan_rate_enabled column 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.factories line 1
  • configs/presets/_builtin/default-minqlxtended/factories/ifrj.factories line 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 runtime must 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: no CHECK constraint, no trigger, no SQLAlchemy validates decorator. 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.factories lines 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_tasks file.

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.py line ~61793 inside _detect_remote_python_version
  • platform.python_version() always outputs a bare X.Y.Z with 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.pyTODO(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.

rage added 2 commits August 23, 2026 08:19
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant