Commit graph

557 commits

Author SHA1 Message Date
Vadim Peretokin
9b8e53b6bf
fix: a queued quit no longer runs from inside a profile save (#9813)
#### Brief overview of PR changes/additions

- `Host::saveProfile()` no longer pumps the event loop between marking
the save as started and making the `QFutureWatcher` that retires that
mark. A quit is queued (`closeMudlet()` arms it on a zero timer), so it
could be delivered in that gap and run the entire application shutdown
from inside the save - and the rest of `saveProfile()` then carried on
using a `Host` that teardown had already destroyed.
- The nested `Host::waitForProfileSave()` was what let that shutdown
through: it was waiting for a finish notification whose watcher did not
exist yet, and its escape hatch counted a thousand event loop passes,
which on a fast machine are over in well under a millisecond. It is
bounded in wall-clock time now, waits out the background writes on every
pass, and the state it prints if it does give up says something that can
be acted on.
- `saveProfileAs()` had the same pump, and announced
`profileSaveStarted()` before finding out it was going to refuse - which
left the editor's Save Profile action disabled and captioned "Saving…"
with no `profileSaveFinished()` ever coming.

#### Motivation for adding to Mudlet

Uninstall a package and quit straight away and Mudlet can crash on the
way out. It showed up on CI as an intermittent SIGSEGV after a fully
green run, on macOS arm64 and windows64 but not on the slower legs,
preceded by `waitForProfileSave() WARNING - save did not complete after
1000 event loop iterations. State: mWritingHostAndModules=true, writers
pending=0` - which is exactly what a save looks like in the window
between the mark and the watcher.

#### Other info (issues closed, discussion etc)

Closes #9807. Adjacent to #9653/#9684 and #9690, and composes with them:
this is the re-entrancy that opened the window rather than another
dangling pointer.

Reproduced deterministically under AddressSanitizer as a
heap-use-after-free in `Host::pendingXmlSaveFutures()` called from
`Host::saveProfile()`, on a `Host` freed by `~Host()`. Draining a save
that a package change has only queued was considered and left out: the
close path never reaches it (it has always either just started a save or
found one running), and it would turn a multi-select module import from
one coalesced save into one full save per module.

**Test case:** uninstall a package, then quit Mudlet immediately - it
exits cleanly. New `ProfileSaveShutdownRaceTest`; both cases verified to
fail against the unfixed source, the shutdown one by aborting the run
under the sanitizer.

Assisted-by: Claude:claude-opus-5
2026-08-13 15:22:50 +02:00
Vadim Peretokin
9ac4f72050
fix: notepad, IRC client and toolbars outliving their profile (#9706)
#### Brief overview of PR changes/additions

- The notepad and IRC client are parentless windows freed only in
`Host::closeChildren()`; a `Host` destroyed without that call orphaned
them. `~Host()` now closes and deletes them, nulling each `QPointer`
first so both teardown paths stay single-delete. Closing (not just
deleting) the notepad also saves the notes and window state.
- The toolbars are not leaked at exit, but survived their profile on
screen holding a freed `TAction`; `~Host()` now deletes them
synchronously.

#### Motivation for adding to Mudlet

Same defect class PR #9700 "fix: trigger editor and deleted item
subtrees leaking memory" fixed for the editor. Two narrow production
paths reach `~Host()` without `closeChildren()` (`requestClose()`
returns early when `mpConsole` is already gone, and `~HostManager` runs
from `~mudlet`), and the test harness takes the second on every run.

#### Other info (issues closed, discussion etc)

Test case: new `HostChildTeardownTest` - three teardown orderings, each
fails without the fix; under ASan+LSan the binary goes from 851,208 to
3,334 leaked bytes (residue is the settings floor fixed in #9694). 79/79
functional tests pass twice; profile-close/quit with the notepad open
are clean under ASan on Xvfb.

Assisted-by: Claude:claude-opus-5
2026-08-11 19:13:19 +02:00
Vadim Peretokin
96335540f5
fix: getWindowGeometry() and windowVisible() answer for the main window (#9714)
#### Brief overview of PR changes/additions
- `getWindowGeometry("main")` and `windowVisible("main")` (and the `""`
spelling of the same) now answer instead of returning `nil, 'window
"main" not found'`; geometry is `0, 0` plus whatever
`getMainWindowSize()` reports, so the two functions cannot disagree
- `windowVisible()` reads `isVisibleTo(mpConsole)` rather than
`isVisible()`, so a profile that is not the front tab - whose whole
console Mudlet hides - stops reporting every one of its labels,
miniconsoles, scroll boxes, command lines and text edits as invisible. A
child of a hidden user window still reports `false`, which is the
documented behaviour
- New two-profile `WindowStateGettersTest` (the busted suite is always
the single front profile, so it cannot reach this), and the two
`UI_spec` specs that asserted the old refusal are replaced

#### Motivation for adding to Mudlet
Both getters are new in 5.0, and as shipped the first one tells a script
the main window does not exist while the second answers wrong for every
profile the user is not currently looking at.

#### Other info (issues closed, discussion etc)
5.0 QA findings C6 (main rejected) and D10 F1 (background profiles). The
background-profile half is the same defect `7bb20fa2c` ("add: widget
state getters for titles, stylesheets, tooltips and scroll bars
(#9645)") fixed for `getScrollBarVisible`; `windowVisible` landed a week
earlier in `1227bc377` ("add: getWindowGeometry(), windowVisible() and
getLabelText() functions (#9528)") and was left reading the widget.

**On excluding `main` - the counter-argument, weighed.** #9528 made that
choice deliberately: it shipped two `UI_spec` specs asserting the
refusal, with the comment "mirrors moveWindow/resizeWindow, which
likewise do not act on main", and the Area 51 draft says the same. So
this overturns a decision rather than filling an oversight. I still
think it is wrong: the message claims a window that manifestly exists
was *not found*, whereas `moveWindow("main", ...)` is a silent no-op and
claims nothing; `windowType("main")` in the same readback family
answers; and `isMain()`, `getRowCount`, `getColumnCount`,
`getWindowWrap` and `getScrollBarVisible` all take `"main"`/`""`.
Refusing is only defensible when there is no sensible answer, and there
is one. The Area 51 text for both functions needs the matching edit
before it goes to the manual.

Two things deliberately left alone, reported rather than fixed:
`Host::windowType()` still special-cases `"main"` without `""`, and a
user element literally named `"main"` is now shadowed by the main
window.


Assisted-by: Claude:claude-opus-5

**Test case:** `print(getWindowGeometry("main"))` and
`print(windowVisible("main"))` answer; with two profiles open,
`createLabel("probe", 0, 0, 50, 50, 1)` in profile A then
`windowVisible("probe")` from a timer while profile B is in front still
returns `true`.
2026-08-07 10:15:30 +02:00
Vadim Peretokin
6bda075517
fix: trigger editor and deleted item subtrees leaking memory (#9700)
#### Brief overview of PR changes/additions

- The editor's seven `delete_*` functions detached the selected
`QTreeWidgetItem` subtree but never freed it, so every deleted item
leaked its tree items and icons. The subtrees are now freed once the new
selection applies, and detached variable items are also purged from
`VarUnit`'s maps - which fixes a pre-existing double free when a table
and its selected child were deleted together.
- `~Host()` now deletes the editor when a profile is destroyed without
`closeChildren()`, and the undo-stack disconnects moved into the
destructor so every destruction path is covered.

#### Motivation for adding to Mudlet

Every trigger/alias/script/timer/key/button/variable deleted in the
editor leaked for the session's lifetime. Found during the LSan baseline
sweep of the functional tests.

#### Other info (issues closed, discussion etc)

Test case: `dlgTriggerEditorUndoRedoTest` with `detect_leaks=1` drops
from 3,382,339 to 13,633 leaked bytes (residue is the settings floor
fixed in #9694). Full suite passes twice; profile-close/quit with open
editors are clean under ASan; runtime unchanged (~4.9s before and
after).

Assisted-by: Claude:claude-fable-5
2026-08-07 06:13:56 +02:00
Vadim Peretokin
fd822ebb83
infrastructure: move module documents into their write jobs instead of copying (#9699)
#### Brief overview of PR changes/additions

- `Host::saveProfile()` cloned each module's XML document into its
background write job while the original sat unread in `writers` as a
save-in-progress token, doubling peak memory per module during saves.
The document is now moved into the job instead (pugixml move, available
since 1.9).
- The async profile save in `runAsyncSave()` had the identical doubling
and gets the same move.

#### Motivation for adding to Mudlet

Follow-up to #9690 "fix: module save reaching into the profile after it
is destroyed" - saves now hold one copy of each document, not two.

#### Other info (issues closed, discussion etc)

Test case: `ModuleSaveTeardownTest`, `PackageUninstallSaveTeardownTest`
and `ProfileRoundTripTest` pass; a 241KB document round-trips
byte-identical through the move under ASan/UBSan.

Assisted-by: Claude:claude-fable-5
2026-08-07 06:13:15 +02:00
Vadim Peretokin
e3204258fe
fix: stop the test-only waitForEvent() wedging Mudlet on macOS (#9691)
#### Brief overview of PR changes/additions

- Fixes #9670 "Mudlet stops responding on macOS part way through the
package specs": the test-only `waitForEvent()` ran a nested
`QEventLoop::exec()`, which the Cocoa dispatcher services by re-entering
`-[NSApplication run]` from inside the timer callout it is already in -
so no Qt timer, including the wait's own timeout, ever fires again.
`EventLoopPump::pumpFor()` drives `processEvents()` against a deadline
instead, which activates Qt's timers on every pass.
- Lifts the macOS pending gate the package specs have carried since they
were written: ~40 specs now run on both macOS legs, green.
- Fixes four pre-existing crashes (each reproduced on `development`
under ASan) when a profile or the application is closed from a handler
delivered during a wait: profile closes are held off while a pump runs,
application shutdowns postpone rather than cancel.

#### Motivation for adding to Mudlet

macOS was the only platform not running the package specs, and the hang
wedged real CI runs.

#### Other info (issues closed, discussion etc)

Test case: #9689's harness with this fix runs the previously hanging
suite to completion (2277/0) on both macOS runners; this PR's own legs:
2370/0 on both macOS arches, 2426/0 on Linux with ASan and leak
checking.

Assisted-by: Claude:claude-opus-5
2026-08-07 06:12:30 +02:00
Vadim Peretokin
887b930e47
fix: module save reaching into the profile after it is destroyed (#9690)
#### Brief overview of PR changes/additions

- A profile save hands the modules that are set to sync to a thread pool
task and returns. Two closes wait for nothing - answering "No" to "Save
profile?", and any close that finds the main console already gone - so
the `Host` is destroyed with the write still going, and the write went
on reading it: its `XMLexport`, and then its name. The job now carries
its own copy of each module's document plus every path it needs, and
`writeModuleFiles()`/`updateModuleZip()` are static, so nothing it
touches belongs to the profile.
- Both save watchers are now owned by the profile that made them. They
were unparented, and the `deleteLater()` they are wired to needs an
event loop that is still running to be delivered - which on the way out
there is not, and for a destroyed `Host` there is no owner left either.
- The unpacked module folder is created before the write that goes into
it rather than after, so a module whose folder the user removed no
longer fails to write and then loses its stale XML from its archive with
no replacement going in.

#### Motivation for adding to Mudlet

Save a profile that has a module set to sync - Lua `saveProfile()`, the
editor's autosave, a package install - then close the profile without
saving. That is a crash or a half-rewritten `.mpackage` on the way out,
i.e. another "Mudlet crashed when I closed it". Same shape as #9653, and
it survived 850 sanitizer runs only because no test profile had a module
in it: with none installed the save's module list comes out empty and
the write returns at its first line, so the entire path was unreachable
in the fixtures.

#### Other info (issues closed, discussion etc)

Test case: install a module, `enableModuleSync()` it, `saveProfile()`,
then close the profile answering "No" - Mudlet exits cleanly and the
module still lands on disk and in its archive.

New `ModuleSaveTeardownTest` is the module coverage that was missing
anywhere in the tree - no C++ test installed a module at all. It holds
the thread pool so the write is provably still queued when the `Host` is
destroyed, then lets it run: without the fix that kills the run under
AddressSanitizer inside `Host::writeModuleFiles()`. The watcher half is
pinned by asserting the watchers are owned by the profile and gone with
it, because LSan cannot see this one (a pending `QFutureCallOutEvent`
keeps it reachable at exit) and the functional tests run with
`detect_leaks=0` anyway. `Package_spec.lua` gains a synced-module save
so the write also runs under the busted job's leak-checked ASan.

Assisted-by: Claude:claude-opus-5
2026-08-06 06:08:40 +02:00
Vadim Peretokin
5dd5b14ee1
fix: package lifecycle - queued save outliving the profile, unremovable archives, module priority (#9684)
#### Brief overview of PR changes/additions

- The profile save that installing or uninstalling a package owes is now
held in a member `QTimer` that the profile close and `~Host()` stop,
instead of a `QTimer::singleShot()` queued on the `Host`: that call was
still delivered after `HostManager::deleteHost()` had destroyed the
profile, and `Host::saveProfile()` then read freed members.
- `installPackage()` refuses an archive it could read no package out of
and takes the folder it unpacked back off disk - but only a folder
inside the profile, since the name can be whatever an untrusted
`config.lua` says. It answered `true` for such an archive before,
leaving something registered nowhere that could never be uninstalled.
- `getModulePriority()` asks `mInstalledModules` whether the module
exists, the same list `setModulePriority()` uses, and reports the
default priority of 0 for one nobody has prioritised yet.

#### Motivation for adding to Mudlet

Uninstall a package, close Mudlet, and the queued save runs against the
destroyed profile - a heap use-after-free on the way out, which is what
a "Mudlet crashed when I closed it" report looks like. Reproduced under
AddressSanitizer, clean afterwards. The other two are smaller but
user-visible: picking the wrong zip in the package manager reported
success and left a folder behind that nothing could remove, and a script
could not tell "module not installed" from "installed, never
prioritised".

#### Other info (issues closed, discussion etc)

Closes #9653, closes #9654, closes #9655.

Test case: `installPackage("something.mpackage")`,
`uninstallPackage("something")`, then close Mudlet straight away - it
exits cleanly; `installPackage()` on a zip with no package XML in it now
answers `nil` plus a message and leaves nothing behind;
`getModulePriority()` on a freshly installed module answers `0`.

The package lifecycle specs carried the last two as `pending()`; both
are flipped to real specs, and a new `PackageUninstallSaveTeardownTest`
covers the save deferral, its coalescing, the profile close, and that a
refused archive can only take its own folder with it.

Assisted-by: Claude:claude-opus-5
2026-08-05 19:55:59 +02:00
Vadim Peretokin
f65323b759
fix: four Lua API corrections - colours, room name offsets, map window state and printTable (#9678)
#### Brief overview of PR changes/additions

- `PadHexNum()` prepends its zero and pads by width rather than by
value, so `RGB2Hex()` stops mangling any colour component below 16 and
`setGaugeText()` stops emitting a broken `<font color>`. The worst case
was silent: `RGB2Hex(200, 11, 12)` returned the well-formed but wrong
`C8B0C0`, painting (200, 176, 192) instead of (200, 11, 12).
- `getRoomNameOffset()` matches a leading minus, so a negative offset
reads back negative - the map renderer already draws it that way.
- The map window functions now answer for the map widget that is
actually on screen. `setMapWindowTitle()`, `getMapWindowTitle()` and
`getMapWidgetGeometry()` report `no floating/dockable type map window
found` after `closeMapWidget()` instead of acting on a widget the script
put away. They go through a new `Host::mapWidget()`, which reads the
dock's own hidden state, so the dock's close button and the map toolbar
button move it too. Reopening is unaffected: the dock is still only
hidden, so its title and geometry survive.
- `printTable()` and `listPrint()` `tostring()` their keys and values
instead of raising `attempt to concatenate` on a table, boolean or
function, and now name themselves when handed a non-table.

#### Motivation for adding to Mudlet

All four fail quietly: a wrong-but-valid colour string, an offset that
comes back with the wrong sign, a map window function that reports
success against a widget that is not on screen, and a debug printer that
errors on exactly the tables you would want to inspect.

#### Other info (issues closed, discussion etc)

Closes #9641, closes #9644, closes #9662, closes #9663.

- #9641 PadHexNum pads hex digits on the wrong side, so RGB2Hex mangles
any colour component below 16
- #9644 getRoomNameOffset drops the minus sign, so negative room name
offsets read back positive
- #9662 closeMapWidget() only hides the map widget, so open and closed
cannot be told apart
- #9663 printTable() errors on any table holding a value that is not a
string or number

Test case: `lua print(RGB2Hex(200, 11, 12))` prints `C80B0C`, not
`C8B0C0`.

`__printTable()` is kept (#9663 "printTable() errors on any table
holding a value that is not a string or number" asked for a decision):
it is a reachable global with its own specs, and wiring it into
`printTable()` would change `printTable()`'s output format. Its
inaccurate "supporting function for printTable()" comment is corrected
instead.

`Geyser.Mapper:show()` now reapplies a title that was set while the
mapper was hidden, which the map widget change would otherwise have
dropped.

Specs added to `GUIUtils_spec`, `TableUtils_spec`, `Mapper_spec` and
`GeyserMapper_spec`; the four `pending()` guards that recorded these
bugs are gone. All of the new assertions fail on development and pass
here.

Before and after for #9662 "closeMapWidget() only hides the map widget,
so open and closed cannot be told apart":


https://github.com/user-attachments/assets/1bf72811-4f79-492f-b449-9013f13984a8

Assisted-by: Claude:claude-opus-5
2026-08-05 19:51:48 +02:00
Vadim Peretokin
7bb20fa2ce
add: widget state getters for titles, stylesheets, tooltips and scroll bars (#9645)
#### Brief overview of PR changes/additions

- Seven state getters that are the inverse of setters we already ship:
`getUserWindowTitle`, `getUserWindowStyleSheet`, `getCmdLineStyleSheet`,
`getLabelToolTip`, `getScrollBarVisible`, `getMapWindowTitle` and
`getMapWidgetGeometry`
- Each returns nil plus a message when the window, label or map widget
it names does not exist, reusing the matching setter's wording so the
pair reports the same problems the same way
- 39 specs added to the existing `UI_spec.lua` and `Mapper_spec.lua`

#### Motivation for adding to Mudlet

#9630's audit left 11 Geyser/UI rows untestable purely because the state
those functions set could not be read back; this tranche unblocks them
exactly as #9528's getters unblocked the geometry specs. Scripts get the
same readback symmetry as a side effect.

#### Other info (issues closed, discussion etc)

One deliberate behaviour change: `enableScrollBar`/`disableScrollBar`
now record what they were asked for, so `getScrollBarVisible` answers
for a profile that is not the front tab (whose whole console Mudlet
hides) instead of reporting every background profile's scroll bar as
gone. Wiki pages for the seven functions to follow in Area 51.

**Test case:** busted 1868 passed / 0 failed / 0 errors / 17 pending
(baseline 1829), green twice on the same isolated profile, plus ctest;
31 of the new specs verified by breaking the getters - wrong return
values fail 20, making the not-found branches succeed fails 7, and
dropping the empty-name and nil handling fails 8 more.

Assisted-by: Claude:claude-opus-5
2026-08-05 06:49:23 +02:00
Vadim Peretokin
fba66d283d
fix: event handlers no longer wipe the caller's Lua stack (#9621)
#### Brief overview of PR changes/additions
- Each Lua-glue function that runs user code (event handlers,
trigger/alias/script bodies, callbacks, the GMCP/MSSP/MXP table
builders) now records its entry stack level and `lua_settop()`s back to
it, and reads its result and error object relative to its own frame
instead of at absolute index 1 (where, mid-dispatch, the caller's
arguments live).
- 17 sites converted across `TLuaInterpreter.cpp` /
`TLuaInterpreterMapper.cpp`; three inferred entry levels
(`callReference`, `parseJSON`, `parseMSSP`, whose callers pre-push for
them) carry a `Q_ASSERT_X`. Left alone: `formatLuaCode` (separate
indenter state) and `initLuaGlobals`/`setupLanguageData` (run while the
state is being built).
- Called from the event loop, entry gettop is 0, so `settop(entry)` is
the old wipe exactly. One deliberate exception: a script that returned
nothing used to be read with `lua_isboolean(L, 0)` - a stale-slot read
in Lua 5.1 - and is now deterministically false.

#### Motivation for adding to Mudlet
These functions finished with `lua_pop(L, lua_gettop(L))`, clearing the
whole shared per-profile Lua stack rather than their own frame. Because
they run synchronously from inside other Lua API C functions, the wipe
destroyed the caller's arguments and pending return values - #9590
shipped as an instance (`ttsSetVoiceByName` returning stack garbage).
This removes the class rather than patching call sites one by one.

#### Other info (issues closed, discussion etc)
Root cause of #9590 (already patched at its call site in #9595); this
makes that ordering safe everywhere.
**Test case:** with neither fix the suite reproduces #9590 verbatim (2
failures, `Passed in: function: 0x...`); with only the class fix and
`ttsSetVoiceByName` still pushing before it raises, the suite is green.
A new `Trigger_spec` case pins the frame-relative reads generically (a
1-fire trigger fires 3 times without them). Busted 1821/0/0 twice (17
pendings are the HTTP fixture), ctest 65/66 (`TKeySequenceEditTest` is
the known bare-Xvfb flake).

Assisted-by: Claude:claude-opus-5
2026-08-04 10:10:18 +02:00
Vadim Peretokin
44da9ec237 fix: setTextFormat leak, createLabel return convention, windowType scrollbox
setTextFormat built a QVector<int> and a QString before validating its
arguments, so every lua_error() on the type paths longjmped past their
destructors. createLabel's two helpers held QStrings across the same kind
of raise. Both now build owning objects only once nothing else can raise.

createLabel discarded its helpers' return count and always returned 1, so a
failed call handed Lua just the message string - truthy, so the failure was
undetectable. It now returns the documented false + message.

windowType gained a scroll box branch, and its not-found message now names
every type it checks.

UI_spec.lua flips the specs that documented the old behaviour and covers
setTextFormat's argument-type errors, which the leak used to block.

Fixes #9576
Fixes #9577
Fixes #9578

Assisted-by: Claude:claude-opus-5
2026-08-02 20:11:00 +02:00
Vadim Peretokin
0f9bc0a4be
infrastructure: decouple the mapper engine from UI dialogs (#9513)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from the map engine: `TMap.{h,cpp}`
no longer owns a `QProgressDialog` (and drops a dead `QFileDialog`
include), and `XMLimport.{h,cpp}` no longer pulls in `QApplication` (the
clipboard read now uses `QGuiApplication::clipboard()`, which lives in
Qt Gui).
- The standalone map-progress dialog (shown when the mapper is not
visible, for map download / XML import and JSON export/import) is now
driven by Qt signals carrying pre-translated payloads; the frontend
(`TMainConsole`) owns the actual `QProgressDialog`, and a user cancel
returns to the engine through `TMap::slot_mapProgressDialogCancelled()`.
- Adds `MapProgressDialogSeamTest` covering the transfer-progress state
machine, a JSON export/import round trip driving the new signals, a
mid-import cancel delivered through the seam (the highest-risk change,
since the JSON reader used to poll `QProgressDialog::wasCanceled()`
synchronously), and an XML map import re-entered from inside a running
JSON operation.

#### Motivation for adding to Mudlet
Second concrete step of the re-scoped libmudlet plan (a Qt-Widgets-free
`mudlet_core` for headless use, testability and WASM). It copies the
seam template established in #9507: core emits a pre-translated payload
-> frontend owns the widget -> a callback slot returns the answer. The
Qt Widgets dependency audit (`cmake/audit-core-widgets.sh`) drops from
**151 to 147** offending files; `TMap.cpp`, `TMap.h`, `XMLimport.cpp`
and `XMLimport.h` are all now clean. The mapper-owned inline progress
path (`dlgMapper`/`T2DMap`, Mudlet's own widgets) is deliberately
untouched here - those move wholesale in the later target-split phase.

#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Existing translations are unaffected: every
progress string keeps its `TMap` `tr()` context, so current translations
carry straight over. Two new strings do arrive, both with `//:`
translator comments - the warnings shown when a map download or an XML
map import is refused because a JSON import/export is already running.
The JSON dialog stays non-modal and the download/import dialog keeps its
modeless styling, each applied by the frontend. The engine keeps its own
`mMapProgressStandalone` / `mMapProgressCancelRequested` /
`mMapProgressStandaloneMaximum` state to replace the widget read-backs
it used to do (`!= nullptr`, `wasCanceled()`, `maximum()`). If a map
operation ever reaches the engine before a console is wired (checked via
`isSignalConnected`), `TMap::warnIfMapProgressUnwired()` logs a loud
`qWarning` rather than silently running with no progress UI.

It also closes a latent null-dereference that exists on `development`
today. With the mapper visible a map download takes the inline-progress
path, leaving `mpProgressDialog` null - so a JSON export started
meanwhile sails past the `if (mpProgressDialog)` "already in progress"
check and creates a dialog of its own. When the download then finishes
inside the `processEvents()` pump the export is running,
`clearTransferProgress()` deletes and nulls *that* dialog, and the
export's next `incrementJsonProgressDialog()` dereferences null. The
engine now records whose dialog is up (`mMapProgressIsTransfer`) so a
transfer only ever closes its own, and `importMap()` refuses to start
while a JSON operation holds the progress - the mirror of the guard
`downloadMap()` has.

Two review-driven details worth flagging: the frontend only wires the
dialog's cancel to the engine when the operation is actually cancelable,
so a non-cancelable local XML import no longer turns a window-close into
a spurious "Map download was canceled" message; and the standalone
download/import dialog is now parented to the console (like the JSON one
always was, and like #9507's package-download dialog), so it centres on
and dies with the profile window. The three `#include <QApplication>`
additions to `Host.cpp` / `dlgTriggerEditor.cpp` /
`dlgConnectionProfiles.cpp` replace the transitive include they used to
get from `XMLimport.h`; all three are already Qt Widgets consumers, so
the audit count is unaffected.

Assisted-by: Claude:claude-opus-4-8
Assisted-by: Claude:claude-opus-5

**Test case:** With a mapper window open, use a game that supports map
download (or call `downloadMap()`) and confirm the progress dialog
shows, updates, and its Abort cancels the download. Then with the mapper
window closed, run `exportJsonMap()` and `importJsonMap()` on a large
map and confirm the non-modal JSON progress dialog appears, updates its
Areas/Rooms/Labels counts, and that clicking Abort during an import
stops it with an "aborted by user" result. Load a local XML map
(Settings -> Map -> load) and confirm closing its progress window does
not print a "Map download was canceled" line. Everything should behave
exactly as on `development`.

#### Demo (before & after)


https://github.com/user-attachments/assets/f1c62580-2d03-4e5b-a6ee-f6e2b78d113d
2026-08-02 15:33:07 +02:00
Vadim Peretokin
1f00cd0ad7
infrastructure: decouple profile management (Host) from UI dialogs (#9514)
#### Brief overview of PR changes/additions
- Removes all raw Qt Widgets usage from `Host.{h,cpp}` so the
`mudlet_core` Qt Widgets audit (`cmake/audit-core-widgets.sh`, added in
#9508) drops both files to zero: the offending-file count goes from 158
to 156 and both move to the "Clean files" list. (The committed
report/baseline are intentionally not regenerated here, to avoid
conflicts with sibling wave-2 PRs.)
- Follows the seam pattern established by #9507: the core (`Host`) emits
Qt signals carrying already-translated `tr()` strings, and the frontend
(`TMainConsole`/`mudlet`) owns the actual widgets.
- The dockable map widget (`mpDockableMapWidget`, a `QDockWidget`) moved
from `Host` to the profile's own `TMainConsole`.
`TMainConsole::createMapperDock()` constructs it and the console's
destructor disposes of it. `Host` still drives it through
`mpConsole->mpDockableMapWidget` (an already out-of-scope pointer per
the split plan) but no longer names any Qt Widgets type. The external
accessors in `mudlet.cpp`/`TDetachedWindow.cpp` gained an `mpConsole &&`
null-guard.
- The mapping-script reminder and package-unpacking progress dialogs are
now shown by the frontend in response to
`signal_showMapperScriptReminder` / `signal_showUnpackingProgress` /
`signal_hideUnpackingProgress`, wired up in
`mudlet::addConsoleForNewHost`.
- `TDockWidget` now sets its own dock features (moved out of
`Host::openWindow`); `Host::setBorders` uses
`QCoreApplication::sendEvent`; and the user-window scrollbar is hidden
via `TConsole::setScrollBarVisible()` instead of reaching into the raw
`QScrollBar`.
- Adds `HostWidgetDecouplingTest` (ephemeral port-0 stub + a real
profile, modelled on `TelnetTlsPromptTest`): verifies the map dock is
created and owned by the console, that `setMapperTitle` routes through
it, that the reminder dialog is raised, and that the unpacking dialog is
replaced then disposed (asserting the replaced dialog is destroyed, not
leaked). Two further tests cover the seams end to end: a real package
install has to reach the dialog through the `addConsoleForNewHost`
wiring, and closing a profile has to take the console-owned map dock
with it.

#### Motivation for adding to Mudlet
Continues the re-scoped libmudlet plan (a Qt Widgets-free `mudlet_core`
for headless use, testability and WASM). `Host` is the second concrete
extraction after `cTelnet` (#9507) and copies its template so later
extractions can follow the same shape.

#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: dialogs keep the same
modality/defaults and all strings stay in `Host`'s translation context,
so existing translations are unaffected. One intentional behaviour
change: failing to load the cosmetic unpacking/reminder `.ui` now warns
and no-ops instead of aborting the package install (a fire-and-forget
signal cannot fail the install back to `Host`), which is strictly
better. The dock's `deleteLater()` cleanup moved from `Host`'s
destructor to `TMainConsole`'s.

Reviewed with the code-reviewer and silent-failure-hunter agents; both
flagged a leak in the unpacking-dialog replace path (parentless dialog
`close()`d instead of `deleteLater()`d) and the missing destructor
cleanup, both fixed and now covered by the test. Expect the `mudlet.cpp`
`addConsoleForNewHost` wiring to conflict with sibling wave-2 PRs; that
is fine.

Assisted-by: Claude:claude-opus-4-8

**Test case:** Open a profile and open the mapper via the Map toolbar
button - it appears docked as before, and its title can be changed with
`setMapperTitle(...)`. Install a `.zip`/`.mpackage` from the package
manager (a normal package, not a module-from-UI and not a script/quiet
install) and confirm the "Unpacking..." progress dialog shows and then
closes. On a profile with no mapper script, open the mapper and confirm
the "you have no mapper script" reminder dialog appears and its link
opens the mapping scripts page.



#### Demo (before & after)

Parity check that the moved dialog/dock flows behave identically before
(development) and after this PR: the package-install "Unpacking..."
dialog and the dockable map widget.


https://github.com/user-attachments/assets/41579b67-7de1-463b-b642-bd7660b52431
2026-08-02 13:04:21 +02:00
Vadim Peretokin
1227bc3778
add: getWindowGeometry(), windowVisible() and getLabelText() functions (#9528)
#### Brief overview of PR changes/additions
- `getWindowGeometry(name)` returns x, y, width, height for any window
element - the exact inverse of moveWindow()/resizeWindow()
- `windowVisible(name)` returns effective visibility (a child in a
hidden user window reports false)
- `getLabelText(name)` returns the text shown on a label
- 25 specs appended to UI_spec.lua

#### Motivation for adding to Mudlet
Long-requested readback symmetry: scripts (and now tests) can finally
query window state they could previously only set.

#### Other info (issues closed, discussion etc)
Part of the Lua API test-coverage program (Wave 0); unlocks ~100
previously untestable functions. Wiki text drafted, to be added to Area
51 once merged.

**Test case:** `createLabel` + `moveWindow`/`resizeWindow`, then
`getWindowGeometry` returns the same values; `hideWindow` flips
`windowVisible` to false; `echo` to a label, `getLabelText` returns it.

Awaiting build/test by a maintainer; squash-merge with:
Assisted-by: Claude:claude-opus-4-8
(Signed-off-by to be added at squash after testing)
2026-07-30 12:04:54 +02:00
Vadim Peretokin
1cf8a59ba2
fix: prevent a rare crash while saving a profile that uses modules (#9559)
#### Brief overview of PR changes/additions
- Move all of `Host::saveProfile()`'s module-save bookkeeping to the
main thread: the module XML documents are now built and their writers
registered up front (`prepareModuleSaves()`), and the background task
only does file I/O (`writeModuleFiles()`: back up, serialize the
prepared documents, update zips).
- The shared `writers`/`modulesToWrite`/`mModulesToSync` containers, and
`Host::xmlSaved()`, are now only ever touched on the main thread; the
background task no longer reads the live trigger/timer/script lists
while building a module document either.
- Each module writer is owned solely by `writers` and dropped on the
main thread, so its `XMLexport` (a main-thread `QObject`) is always
destroyed on its own thread; `profileSaveFinished` is now always emitted
on the main thread.

#### Motivation for adding to Mudlet
Concurrently mutating these implicitly-shared Qt containers from both
the main thread and the QtConcurrent pool thread is undefined behaviour
and matches a real heap-corruption crash cluster.

#### Other info (issues closed, discussion etc)
- This is the pre-existing module-save data race documented in-code by
#9557 (which fixed the profile-save half and explicitly deferred module
writing to its own PR). Same crash signature: `STATUS_HEAP_CORRUPTION`,
Sentry cluster MUDLET-32 / MUDLET-2S / MUDLET-48, frames in lua51 /
libpugixml / Qt6Core.
- The approach mirrors the existing profile-save path (build the pugixml
document on the main thread, serialize it on a background thread) and
reuses the `pendingXmlSaveFutures()` snapshot pattern. Module
serialization, backups, zip updates and the wait-for-save semantics
(`waitForProfileSave`, `currentlySavingProfile`, single-in-flight-save
gating) are preserved.
- Known-benign ordering: the completion handler clears
`mWritingHostAndModules` and emits `profileSaveFinished` before running
`reloadModules()`. There is no current `profileSaveFinished` handler
that re-enters `saveProfile()`; a future one must not, as it would
repopulate `mModulesToSync` that `reloadModules()` then consumes.
- Data races are non-deterministic and the existing save tests
synchronise via `waitForProfileSave()`, so this cannot be exhibited by a
functional test. Correctness rests on every access to the four shared
members now being single-threaded; a ThreadSanitizer build runs the save
path with no data race reported in this code.

**Test case:**
1. In a profile with a module that has "sync" enabled, edit the module
and save the profile (or close the profile) repeatedly - the module
still serialises to disk correctly and syncs to other open profiles.
2. `ctest -R "ResetProfileTest|ProfileRoundTripTest|MapRoundTripTest"`
in an ASan (default Debug) build - all pass.


Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 12:02:53 +02:00
Vadim Peretokin
41f61e9698
Fix: crash when a button uninstalls its own package (#9558)
#### Brief overview of PR changes/additions

A toolbar or menu button whose Lua script calls `uninstallPackage()` on
its own package used to crash Mudlet. `ActionUnit::uninstall()` deleted
the package's buttons immediately, but the clicked button is still
inside `TAction::execute()`, which reads `this->mpHost` (to restore
command-line focus) *after* the script returns - a heap-use-after-free.

This applies the same processing-depth deferral already used for
triggers/aliases/keys and timers/scripts: while a button's script is
running, `uninstall()` deactivates and queues that package's actions
instead of deleting them, and the delete happens later in `doCleanup()`
once `execute()` has unwound. `ActionUnit` was the last object unit
still deleting immediately. The queued deletes are flushed by the button
dispatchers once the click returns and by the existing per-line /
periodic `doCleanup()` calls in `Host`, and a seen-set guards against a
double free from a re-entrant uninstall.

#### Motivation for adding to Mudlet

It is a hard crash reachable with a completely normal setup - a
"reload/update this package" button that reinstalls itself is a common
pattern.

#### Other info (issues closed, discussion etc)

Completes the self-uninstall use-after-free hardening across all object
units - a follow-up to #9557 (timers/scripts) and #9383
(triggers/aliases/keys); actions were the one remaining unguarded unit.
The crash is reported in Sentry as MUDLET-32 / MUDLET-2S / MUDLET-48.

**Test case:**

`test/functional_tests/ActionSelfUninstallTest.cpp` (Qt Test, built with
AddressSanitizer) builds a synthetic package whose toolbar button
uninstalls its own package, gives it a real toolbar widget, invokes the
button, and asserts no crash, the package removed, and the deferred
action freed cleanly with no double free. It trips a heap-use-after-free
in `TAction::execute()` on the pre-fix code and is ASan-clean with the
fix.

To reproduce by hand:
1. Create a package containing one button whose script is
`uninstallPackage("<that package's name>")`.
2. Click the button.
3. Before this PR: Mudlet crashes. After: the package uninstalls cleanly
and focus returns to the command line.



Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 12:02:32 +02:00
Vadim Peretokin
d4376326bb
fix: stop crashes while saving and profiles losing their triggers (#9557)
#### Brief overview of PR changes/additions
- Profile loading now only considers real `*.xml` saves: an empty
QSaveFile temporary left behind by a crash during a save can no longer
be loaded as "the profile", which made a profile open with its
connection settings intact but every trigger/script seemingly gone.
Affected profiles heal themselves on next load by falling back to the
newest real save.
- Packages that uninstall themselves from their own timer script or
event-handler script (a common auto-updater pattern) no longer free the
very objects still executing: `TimerUnit`/`ScriptUnit` uninstall now
defers deletion while `TTimer::execute()` / `Host::raiseEvent()` are on
the call stack, completing the #9337/#9383 fix that already covered
triggers/aliases/keys. Deferred timer deletes are flushed before the
queued post-uninstall save runs, so removed items cannot be serialized
back into the profile.
- `Host::saveProfile()`'s background module task no longer reads
`writers`/`saveFutures` concurrently with the main thread (data race in
the profile save path).

#### Motivation for adding to Mudlet
Fixes a real-world heap-corruption crash cluster (Sentry MUDLET-32 /
MUDLET-2S / MUDLET-48: `STATUS_HEAP_CORRUPTION` on 4.21.0/4.21.1, frames
touching lua51/Qt6Core/libpugixml, breadcrumbs showing package uninstall
activity around saves) and the profile data loss it caused.

#### Other info (issues closed, discussion etc)
Root cause of the crashes: #9111 (in the 4.20.1 → 4.21.0 window) changed
the `*Unit::uninstall()` methods from unregister-only to immediate
`delete`. A package script calling `uninstallPackage()` on its own
package then freed objects still on the call stack - use-after-free that
poisons the heap, typically detected slightly later during the
background save serialization (hence the pugixml/lua frames, aborts
mid-save, and zero-byte `....xml.XXXXXX` QSaveFile leftovers in
`current/`). #9383 fixed the trigger/alias/key cases; this completes
timers and scripts, which reproduce under ASan on current development
(heap-use-after-free in `Tree<TScript>::isActive()` /
`TTimer::execute()`).

Data-loss mechanism (generic): a crash mid-save leaves a 0-byte
QSaveFile temporary as the newest file in `current/`;
`mudlet::loadProfile()` picked the newest file of any name, tried to
load the empty temp, and the profile opened "gutted" (connection details
live in separate files and survived). Verified end-to-end with affected
profile data and covered by a synthetic regression test.

Both new functional tests fail on pre-fix code
(`PackageSelfUninstallTest` trips ASan heap-use-after-free;
`ProfileLoadTempFileTest` reproduces the data loss) and pass with the
fix; full functional suite green (24/24).

Known remaining (pre-existing) issue documented in-code at
`Host::pendingXmlSaveFutures()`: module writing still touches `writers`
from the background task for profiles that use modules; fixing that
properly means moving module serialization back to the main thread and
deserves its own PR.

**Test case:**
1. Create a package containing a timer or event-handler script that
calls `uninstallPackage()` on its own package, and let it fire - no
crash, package cleanly removed, next save does not resurrect it.
2. Simulate an interrupted save: place an empty file named like
`2026-01-01#12-00-00.xml.AbCdEf` in a profile's `current/` folder with
the newest timestamp - the profile still loads the newest real save with
all triggers intact, and the temporary no longer appears in Connect →
Options → Profile history.
3. `ctest -R "ProfileLoadTempFileTest|PackageSelfUninstallTest"` in an
ASan (default Debug) build.


Assisted-by: Claude:claude-fable-5
Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-30 10:49:10 +02:00
Vadim Peretokin
9007603096
infrastructure: add waitForEvent test helper for async Lua API tests (#9527)
#### Brief overview of PR changes/additions
- New test-mode-only `waitForEvent(eventName, timeoutMs)`: pumps a
nested event loop so busted specs can observe timers, network and Mudlet
events (inert outside MUDLET_TEST_MODE)
- Event args are snapshotted into the Lua registry so tables survive the
event's own cleanup
- 13 specs in MudletBusted_spec.lua covering firing, arg round-trips,
timeouts, filtering and nested waits

#### Motivation for adding to Mudlet
Busted blocks the event loop, so nothing asynchronous was testable; this
is the keystone for timer/HTTP/media/MMCP test coverage.

#### Other info (issues closed, discussion etc)
Part of the Lua API test-coverage program (Wave 0). Sabotage-verified:
7/13 specs fail when the capture hook is disabled.

**Test case:** build, then run the busted suite (all green);
waitForEvent specs exercise a real tempTimer firing through the helper.

Awaiting build/test by a maintainer; squash-merge with:
Assisted-by: Claude:claude-opus-4-8
(Signed-off-by to be added at squash after testing)
2026-07-29 19:36:28 +02:00
Vadim Peretokin
775e223405
infrastructure: clean vestigial widget includes from the Lua engine files (#9526)
#### Brief overview of PR changes/additions

Step 1 of the libmudlet Wave 3 de-widgeting work: a cleanup +
correctness pass over the `TLuaInterpreter*` engine files.

- Delete the dead `#include <edbee/texteditorwidget.h>` from
`TLuaInterpreter.h` (no `edbee`/`TextEditorWidget` use anywhere in the
TLuaInterpreter family).
- Drop the copy-pasted, unused `QFileDialog`/`QTableWidget`/`QToolTip`
include block from the seven `TLuaInterpreter*` files carrying it,
keeping only the includes each file actually uses.
`TLuaInterpreterMudletObjects.cpp` keeps `QFileDialog` (used by
`invokeFileDialog`); `TLuaInterpreter.cpp` gains an explicit
`<QApplication>` for its remaining `QApplication::alert`, which the
removed headers had been supplying transitively.
- Retarget two Widgets-free statics for audit accuracy:
`QApplication::clipboard()` -> `QGuiApplication::clipboard()` and
`QApplication::applicationPid()` -> `QCoreApplication::applicationPid()`
(both are inherited statics, so behaviour is identical).
- Fix a null-path bug: `Host::echoWindow` and `Host::pasteWindow`
returned `-1` (which promotes to `true`) when the console is null, a
silent success-lie on a torn-down profile. They now return `false`,
matching the existing missing-window path, so the checked `echo` caller
yields the honest `nil + message` instead of a false success.

#### Motivation for adding to Mudlet

Part of the incremental libmudlet refactor (#8681, #9011) that drives
raw Qt Widgets coupling out of `mudlet_core`. This removes 5 files from
the `cmake/audit-core-widgets.sh` Widgets-dependent set (156 -> 151)
with no behaviour change on the GUI path, and folds in an independent
correctness fix for the `-1`->`true` null-path pattern.

#### Other info (issues closed, discussion etc)

Relates to #8681 and #9011. Closes no issues.

**Test case:**

1. Build (this was verified with Qt 6.12.0, Ninja, ASan) and run the C++
suite:
`ctest --output-on-failure` -> 48/49 pass. The only failure is
`TKeySequenceEditTest`, a known pre-existing headless-focus failure
(`qWaitForWindowActive` under Xvfb), unrelated to these changes.
2. `bash cmake/audit-core-widgets.sh --summary` reports 151 files depend
on Qt Widgets (was 156); the five now-clean files are
`TLuaInterpreterUI/Mapper/Networking/Discord/TextToSpeech.cpp`.
3. Lua sanity for the retargeted statics: `getClipboardText()`,
`setClipboardText("hi")`, and `getProcessID()` behave exactly as before.
4. Null-path fix: `echo` targeting a window that does not exist returns
the honest `nil + "console/label '...' does not exist"` rather than
reporting success. (The null-console branch it fixes is reachable when a
profile's main console has been torn down.)

Assisted-by: Claude:claude-opus-4-8
2026-07-28 16:26:02 +02:00
elements-of-boredom
ae6b017c81
add: full-window background image/gradient support (#9394)
#### Brief overview of PR changes/additions
- Updates `setBackgroundImage(target, imageLocation, [mode],
[fullWindow])` / `resetBackgroundImage(target, [fullWindow])` Lua
functions to support painting a background image or gradient behind the
entire main console window, independent of `main`'s own size/position.
- adds a new `cover` mode for true aspect-preserving scale-to-fill;
gradients are supported via the existing `style` mode using Qt's native
QSS gradient syntax
(`qlineargradient`/`qradialgradient`/`qconicalgradient`).
- Unlike previous `setBackgroundImage("main", ...)`, the background
stays fixed and uncropped when `main` is resized via `setBorderSizes()`
or side panels are added.

#### Motivation for adding to Mudlet
Lets players and package authors theme the whole Mudlet window with a
persistent image or gradient, without hacky miniconsole/image-slicing
workarounds that break whenever borders or panel layout change.

#### Other info (issues closed, discussion etc)
Closes #9392

**Test case:** 
`setBorderSizes(80,80,80,80)` then 
`setBackgroundImage("main", getMudletHomeDir().."/pathto/asset.png",
"cover", true)`

Image fills the border margin around `main`, uncropped, and stays fixed
through further `setBorderSizes()` changes. Combine with
`setBackgroundColor("main", 0,0,0,140)` for a tinted readable box.
`resetBackgroundImage("main", true)` removes it.

`setBackgroundImage("main","background: qlineargradient(x1:0, y1:0,
x2:0, y2:1, stop:0 #1a1a2e, stop:1 #16213e);", "style", true)` paints a
gradient behind "main"
`resetBackgroundImage("main", true)` removes it.

`setBackgroundImage("main", getMudletHomeDir().."/bgtest-assets/bg.png",
"border")` - Continues to add image as before

Example:
<img width="1482" height="846" alt="Screenshot 2026-07-08 at 2 59 17 PM"
src="https://github.com/user-attachments/assets/e2ec9835-a5c3-4f1a-a246-c45de6f80062"
/>
2026-07-27 22:14:58 +02:00
Morquin
4f259afa0a
Fix: character-at-a-time warning misfires on password prompts (#9391)
#### Brief overview of PR changes/additions
- Only warn about character-at-a-time mode (server ECHO +
SUPPRESS-GO-AHEAD) once that combination has outlived a submitted input
line, instead of the instant both options are negotiated.
- Detect it by behaviour: when a line is sent while ECHO+SGA are active,
arm a short one-shot timer; cancel it if the server releases ECHO (WONT
ECHO); only warn if ECHO+SGA are still active when the timer fires.

#### Motivation for adding to Mudlet
Servers that mask a password advertise SUPPRESS-GO-AHEAD up front and
take over ECHO transiently (WILL ECHO ... WONT ECHO) around the masked
field, which #8825 misread as character-at-a-time mode - so the warning
appeared at the login/password prompt of a large class of well-behaved
servers.

#### Other info (issues closed, discussion etc)
Follow-up to #8825. Only the warning's trigger changes; the
`sysCharacterModeDetected` Lua event now fires at the same confirmed
point. Detection window is `CHARACTER_MODE_DETECT_MS` (3000 ms).

**Test case:** Connect to a server that masks the password via telnet
ECHO (e.g. any GoMud-based game) and log in - the "character-at-a-time
mode" warning no longer appears and password masking still works. A
genuine character-at-a-time server (ECHO+SGA kept active across
successive lines) still triggers the warning.

Try connecting to:
gomud.net port 33333 or willowdalemud.com port 1111

---------

Signed-off-by: Morquin <morquin@morquin.dk>
2026-07-27 09:47:44 +00:00
Vadim Peretokin
efc42fac05
infrastructure: harden the build for the minimum supported Qt version (#9510)
#### Brief overview of PR changes/additions

- Include QJson/QtCore headers directly in the 16 files that only
reached those types through transitive includes; headers forward-declare
where a reference suffices
- Pass the MMCP command bytes to QString::arg() as explicit QChar (wire
bytes unchanged)

#### Motivation for adding to Mudlet

An audit comment in #9011 reported that development does not build at
the declared Qt 6.8.2 lower bound; building against official Qt 6.8.2
binaries shows it actually builds cleanly today, but only via transitive
includes and arg() overload-set details that shift between Qt releases -
this PR makes both explicit so the lower bound keeps working.

#### Other info (issues closed, discussion etc)

Relates to #9011 (not closed by this PR).

Verification of the audit's two Qt 6.8.2 claims, done against official
Qt 6.8.2 gcc_64 binaries:

- Unmodified development configures and builds completely (387/387
targets, all functional tests link) at 6.8.2, so the reported QJson
include errors and MMCP arg() compile failures did not reproduce. This
branch builds green at 6.8.2 as well.
- On the arg() claim specifically: 6.8.2's qstring.h declares
`arg(char)` unconditionally, so the two live single-arg sites in
MMCPServer.cpp already compiled. The genuinely 6.8.2-problematic pattern
- multi-arg `.arg(char, ...)`, which on 6.8.2 resolves to `arg(char a,
int fieldWidth)` and would silently eat the second byte as a field width
- only occurs in commented-out code (MMCPServer.cpp around lines 534 and
1054). If that code is ever revived, it needs the QChar wrapping.
- MMCP byte-equivalence: a small harness compiled against Qt 6.12 shows
the old and new forms produce identical toLatin1() frames (0x06 for
TextGroup, 0xFF for End). On 6.8.2, `arg(char)` forwards to QLatin1Char
- the same Latin-1 mapping `QChar(char)` uses - so frames are
byte-identical there too.

Assisted-by: Claude:claude-opus-4-8
Assisted-by: Claude:claude-fable-5

**Test case:**

- Build against Qt 6.8.x: done for this PR with official 6.8.2 binaries
on Linux - full build green on this branch and on unmodified
development.
- At minimum: the current build stays green (CI covers newer Qt only).
2026-07-26 13:30:15 +02:00
Vadim Peretokin
3a9c6f2615
fix: profile reset no longer forgets which variables to save (#9484)
#### Brief overview of PR changes/additions
- Carry the Variables view's "save this variable" and "hidden by user"
sets across resetProfile(), instead of losing them with the rebuilt Lua
state
- Re-hide Mudlet's internal Lua API after reset (same call the normal
profile load uses), so the Variables view shows user variables only
- 3 regression tests including an end-to-end check that a saved variable
still lands in the profile XML after a reset

#### Motivation for adding to Mudlet
Since the #9430 crash fix, the first profile save after resetProfile()
silently stripped all user-saved variables from the profile, and the
Variables view flooded with hundreds of internal entries.

#### Other info (issues closed, discussion etc)
Follow-up to #9430; found by a post-merge QA audit and confirmed with a
failing test before the fix.

**Test case:** Mark a variable as saved in the Variables view, run `lua
resetProfile()`, save the profile - the variable survives a restart, and
the Variables view lists only user variables.

Assisted-by: Claude:claude-fable-5
2026-07-25 20:25:26 +02:00
Vadim Peretokin
cb402cc3e6
infrastructure: use std::chrono literals for time durations (#9493)
#### Brief overview of PR changes/additions

Convert raw millisecond integer literals at time-duration call sites to
`std::chrono` literals, and add `#include <chrono>` to each touched
translation unit. Examples:

- `QTimer::singleShot(0, ...)` → `QTimer::singleShot(0ms, ...)`
- `mpTimerReplay->setInterval(1000)` → `setInterval(1s)`
- `mPendingTimer.start(60000)` → `start(1min)`
- `QObject::startTimer(50)` → `startTimer(50ms)`
- `QTest::qWait(100)` → `QTest::qWait(100ms)`
- `QThread::msleep(10)` → `QThread::sleep(10ms)`

This is a semantics-preserving refactor - every duration is kept exactly
equal to before (e.g. `1000` ms becomes `1s`, `60000` ms becomes
`1min`). No behavioural change.

#### Motivation for adding to Mudlet

Chrono literals make time durations self-documenting and type-safe. `1s`
/ `100ms` read unambiguously where a bare `1000` / `100` forces the
reader to remember each API's unit, and the compiler now rejects unit
mismatches. Only genuine duration arguments were converted - loop
counts, scroll-line counts, sizes, ports and the like were deliberately
left as plain integers.

All targeted APIs provide `std::chrono` overloads in the minimum
supported Qt (6.8.2): `QTimer::singleShot`/`start`/`setInterval` (5.8),
`QObject::startTimer` (5.9), `QThread::sleep(std::chrono::nanoseconds)`
(6.6) and `QTest::qWait(std::chrono::milliseconds)` (6.7).

#### Other info (issues closed, discussion etc)

Test case: the full application builds cleanly and the entire functional
`ctest` suite passes. The only failing test is the known, pre-existing
`PasswordMigrationTest` LSan exit-leak (GTK3/fontconfig noise), which is
unrelated to this change.

Assisted-by: Claude:claude-opus-4-8
2026-07-25 20:24:31 +02:00
Vadim Peretokin
b649b60f0f
add: keyboard shortcuts to switch between game tabs (#9460)
#### Brief overview of PR changes/additions
- Ctrl+Tab / Ctrl+Shift+Tab cycle through open profile tabs (with
wraparound); Ctrl+1-9 jump straight to a tab. On macOS: physical
Ctrl+Tab and Cmd+1-9.
- All 11 shortcuts are remappable per profile in Preferences >
Shortcuts.
- A one-time dismissible callout points out the new shortcuts when a
second profile opens.
- Replaces the old command-line-only Ctrl+Tab handling, which never
worked on macOS and didn't move the tab bar highlight. The caret-mode
Ctrl+Tab accessibility toggle still takes precedence when selected.

#### Motivation for adding to Mudlet
Switching between open games needed the mouse - the long-standing ask in
#1160 - and macOS had no working way to do it at all (needs the
QShortcut path due to QTBUG-12232, fixed for shortcuts in Qt 6.7.1+).

#### Other info (issues closed, discussion etc)
Closes #1160

Needs a test on macOS before merge (physical Ctrl+Tab / Cmd+1-9 delivery
via Qt 6.8's performKeyEquivalent fix) - this was verified on Linux
only.

**Test case:** Open 3 profiles. Ctrl+Tab cycles forward and wraps,
Ctrl+Shift+Tab cycles backward, Ctrl+2 jumps to the second tab - the tab
bar highlight follows each switch. Remap "Next profile" in Preferences >
Shortcuts and confirm the new key works. With Preferences >
Accessibility caret shortcut set to Ctrl+Tab, Ctrl+Tab toggles caret
mode instead of switching.


https://github.com/user-attachments/assets/2f6a3ab7-1212-4d4a-9ad5-ed94dd0dc9b3
2026-07-20 22:46:05 +02:00
Vadim Peretokin
b23d6f788e
Infrastructure: fix else-after-return in codebase (#9096)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Remove unnecessary else/else-if after return, break, continue, and throw
statements - and in places where fixing them is more trouble than its
worth, added NOLINT.
#### Motivation for adding to Mudlet

https://clang.llvm.org/extra/clang-tidy/checks/readability/else-after-return.html,
so it doesn't pop up in PR reviews.
#### Other info (issues closed, discussion etc)

---------

Co-authored-by: Stephen Lyons <slysven@virginmedia.com>
Co-authored-by: Vadim Peretokin <vadi2@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 18:39:57 +02:00
Vadim Peretokin
8d4f110c28
fix: no more crash when viewing Variables after resetting a profile (#9430)
#### Brief overview of PR changes/additions
- `resetProfile()` replaces the Lua state, but `LuaInterface` kept a raw
pointer to the closed one; opening the editor's Variables view then read
freed memory and crashed
- Recreate the `LuaInterface` right after the state swap so the
Variables view works on the new state

#### Motivation for adding to Mudlet
Viewing variables after a profile reset reliably crashed Mudlet
(reported as a Windows CTD; reproduces on all platforms under ASAN).

#### Other info (issues closed, discussion etc)
Fixes #9382

**Test case:** open a profile, run `lua resetProfile()`, open the editor
and click Variables - the list populates instead of crashing.



https://github.com/user-attachments/assets/392992cf-a7d1-449d-a243-caa036a7cb75

Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
Co-authored-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
2026-07-16 10:00:47 +02:00
Mike Conley
a97186ad5f
Add: sign in to supported games using your browser (e.g. Google, Discord, or the game's own account) (#9378)
#### Brief overview of PR changes/additions

Adds client-side support for the GMCP `Char.Login` v2 sign-in flow. When
a game offers it, Mudlet automates sign-in *around the game's own login
screen* — it renders no sign-in UI of its own:

- Hands off to the game's interactive screen with an empty
`Char.Login.Credentials {}` when nothing is stored, so the player picks
a provider (Google, Discord, GitHub, the game's own account, …) as text
on the game's own screen.
- Opens the sign-in URL the server pushes (`Char.Login.URL`) in the
system browser — but only after the player has acted on this connection,
never unprompted.
- Autofills a stored character name + password when the profile has
them.
- Persists and replays the server's reconnect token (`Char.Login.Token`
/ `Char.Login.Reconnect`) for instant, password-less reconnects, with a
"forget saved sign-in" control in Preferences → Connection.
- Resumes the *remembered* provider's browser sign-in without a menu
when a saved token has expired or been revoked (`Char.Login.Credentials
{account, provider}`), falling back to the interactive hand-off only
when no provider is remembered.
- Handles token rotation and multiple devices safely: overwrites the
saved token when the server rotates it, and if another running Mudlet
instance sharing the profile's keychain rotates the token mid-flight,
replays the fresh token instead of discarding it.
- For a game that is its own OpenID Provider over TLS, optionally runs
the client-driven PKCE flow end to end (`Char.Login.AuthCode`).

#### Motivation for adding to Mudlet

Modern games are moving to browser-based single sign-on; this lets
Mudlet players use those accounts directly, and reconnect without
re-entering anything — while classic character-name/password logins keep
working unchanged.

#### Other info (issues closed, discussion etc)

- Reworks the approach to resolve @vadi2's UX feedback
(https://github.com/Mudlet/Mudlet/pull/9373#issuecomment-4865669499):
there is **no client pop-up and no in-client chooser**. The game owns
the sign-in screen and offers the choice as text; Mudlet only automates
the mechanical hooks around it (autofill, open URL, save token, replay
token).
- Supersedes and closes #9373.
- Implements the revised v2 draft spec, inspired by #9354.
- Covered by new tests: `OAuthClientFlowTest` (OIDC discovery, PKCE,
loopback capture) and the `GMCPCharLoginTest` functional suite driving a
GMCP server stub through the full client flow.
- Try it out on StickMUD.



https://github.com/user-attachments/assets/566947f6-4f43-4bff-b98c-9328b7a40a2d

---------

Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-07-14 12:20:02 +00:00
Stephen Lyons
84751258ff
Infrastructure: rename ActionUnit::UpdateToolbar() to ActionUnit::UpdateAllToolbars() (#9359)
#### Brief overview of PR changes/additions
A rename to more accurately reflect that the method works on ALL
toolBars/easyButtonBars and not just a single one.

#### Motivation for adding to Mudlet
To improve nomenclature.

#### Other info (issues closed, discussion etc)
There should be no functional changes with this item.

---------

Signed-off-by: Stephen Lyons <slysven@virginmedia.com>
2026-06-27 00:25:01 +00:00
Vadim Peretokin
48a6d9e184
improve: make small area centering opt-in via Mudlet.ini (#9351)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
The area auto-centering feature (#8766) - which centers the map on a
whole area when it fits entirely in the viewport - did not work well for
most users, who would rather have it off. Make it opt-in instead: it is
now disabled by default and enabled via the mapCenterSmallAreas key in
Mudlet.ini, following the autosaveIntervalMinutes pattern.

See also #8982.
#### Motivation for adding to Mudlet
Better player experience
#### Other info (issues closed, discussion etc)

Signed-off-by: Vadim Peretokin <vperetokin@hey.com>
2026-06-22 19:04:25 +02:00
John McKisson
a4f7829226
Fix: Regression of resetProfile handling of labels (#9255)
#### Brief overview of PR changes/additions

Fixes issue https://github.com/Mudlet/Mudlet/issues/9254 where a
mouseover event in labels placed in geyser containers throw errors after
a resetProfile due to lua_unref in the wrong lua state during the reset
process.

Unit test and fix researched and applied by by Claude Opus.

Draft to see if a unit test catches the error first before I add the
fix.

#### Motivation for adding to Mudlet

#### Other info (issues closed, discussion etc)
2026-05-08 06:29:22 +02:00
Nick Shearer
cea7b868ec
improve: improve memory safety by using smart pointers (#9239)
### Refactor: replace raw pointer ownership with smart pointers across
core subsystems

  #### Brief overview of PR changes/additions
Replaces raw pointer ownership patterns with `std::unique_ptr` and
`std::map`
  across several core subsystems:

- **Host**: `mStopWatchMap` (`QMap<int, stopWatch*>` → `std::map<int,
unique_ptr<stopWatch>>`), `profileShortcuts` (`QMap<QString,
QKeySequence*>` → `std::map<QString, unique_ptr<QKeySequence>>`).
Removes `qDeleteAll` in destructor and `delete mMMCPServer`.
  - **TMap**: `mpRoomDB` raw pointer → `unique_ptr`
  - **VarUnit**: `base` raw pointer → `unique_ptr`
- **TTrigger**: condition map storage converted to `unique_ptr`,
destructor simplified
- **discord**: handler and presence maps converted from raw pointer
`QMap` to `unique_ptr` + `std::map`
  - **Updater**: `mFeed` and `mUpdateDialog` converted to `unique_ptr`

  #### Motivation for adding to Mudlet
These patterns were identified as sources of memory leaks and potential
use-after-free bugs. Using smart pointers makes ownership explicit,
eliminates manual cleanup code, and ensures correct destruction even on
early-exit paths.

#### Other info (issues closed, discussion etc)

sorry this one is still pretty big, but most of the changes are the same
for each thing so reviewing them together probably makes sense. sadly
there isn't much to see here other than no slow uptick of heap size :-[

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 06:42:09 +02:00
Stephen Lyons
113218930f
Infrastructure: Swap out QtConcurrent module header for sub-module ones (#9246)
#### Brief overview of PR changes/additions
The Qt documentation for `QtConcurrent` points out:
> If you include the `<QtConcurrent>` header, the entire Qt Concurrent
module with the entire Qt Core module will be included, which may
increase compilation times and binary sizes. To use individual functions
from the QtConcurrent namespace, you can include more specific headers.
>
> The table below lists the functions in the QtConcurrent namespace and
their corresponding headers:

|Function|Header|
|--------|------|
|`QtConcurrent::run()`|`<QtConcurrentRun>`|
|`QtConcurrent::task()`| `<QtConcurrentTask>`|

|`QtConcurrent::filter()`,<br>`QtConcurrent::filtered()`,<br>`QtConcurrent::filteredReduced()`|`<QtConcurrentFilter>`|
|`QtConcurrent::map()`,<br>`QtConcurrent::mapped()`,<br>`QtConcurrent::mappedReduced()`|`<QtConcurrentMap>`|

#### Motivation for adding to Mudlet
To speed up the build a little by removing stuff that isn't needed.

#### Other info (issues closed, discussion etc)
In doing this I happened to start cleaning up a couple of header files
`T2DMap.h` and then `mudlet.h`, I then got into converting some
`#include`s into forward declarations in a "include-what-you-use" move.
This then rippled through into a (more than 10!) number of files but
should "improve" things.

Note that the ordering of `#include` in many files seems to be rather
haphazard and is due for some serious overhaul - I suggest that we
should actually declare an "official" style for this project so that
everyone knows what it is.

**During the CI/CB process I discovered that Linux and then MacOS builds
were failing because the file referred to by the `#include
<QtConcurrentTask>` header file was missing, yet was present on my local
PC when I was using the Qt framework from the On-line installer.
Initially I suspected a Debian (and then Devuan - as the packaged
version on my own machine also had this defect AND Ubuntu) package
problem; however it now seems to be an upstream Qt issue as the various
Qt versions & OS combinations suggest that Qt themselves fixed it for Qt
6.10:**
| OS     | QtVersion             | Missing header |
|--------|-----------------------|----------------|
| Windows| 6.11.0 package        |       No       |
| Devuan | 6.8.2 package         |      Yes       |
| Devuan | 6.10.0 online install |       No       |
| Ubuntu | 6.9.0 package         |      Yes       |
| Debian | 6.8.2 package         |      Yes       |
| MacOS  | 6.9.0 package         |      Yes       |

**To fix this I reverted to an `#include <qtconcurrenttask.h>` for Linux
and MacOS builds - although it would probably have been better to make
it conditional on the Qt Version instead...**

*I have reported this upstream to Debian - see:
https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1135197*

---------

Signed-off-by: Stephen Lyons <slysven@virginmedia.com>
2026-04-29 13:35:29 +01:00
Mike Conley
3886eec794
Fix: stop scripted package installs from stealing window focus (#9236)
#### Brief overview of PR changes/additions

When a Lua script calls `installPackage()` or `installModule()`, Mudlet
no longer shows the brief "Unpacking…" pop-up dialog. The dialog still
appears for installs you start from the package manager UI.

#### Motivation for adding to Mudlet

The pop-up was raising and activating Mudlet's window every time a
script installed a package, pulling focus away from whatever you were
doing in another application. This was especially disruptive for package
authors using build tools like Muddler that auto-reinstall on save.

#### Other info (issues closed, discussion etc)

Closes #9170.
2026-04-29 06:57:14 +02:00
Nick Shearer
55cad851d9
fix: make FontManager track fonts per-profile (#9238)
#### Brief overview of PR changes/additions
  `FontManager::loadFont()` and `unloadFonts()` now take a `profileName`
parameter. Font registration keys are prefixed with the profile name, so
  two profiles loading a font file with the same filename are tracked
  independently. Adds two helpers: `fontAlreadyLoaded()` and
  `rememberFont()`. Call sites in `Host::installPackageFonts()` and
  `Host::uninstallPackage()` updated accordingly.

  #### Motivation for adding to Mudlet
Previously all profiles shared a single flat registry keyed on filename.
If two profiles loaded a font with the same name, unloading one
profile's
fonts could unload the other's copy too, leaving the second profile with
  missing fonts.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:38:11 +02:00
Vadim Peretokin
91c5bf18a7
Improve: inline map download dialog (#9204)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Inline map download dialog to remove an additional pop-up when the map
is opened.
#### Motivation for adding to Mudlet
Less pop-ups in desktop UX, the better
#### Other info (issues closed, discussion etc)
[Screencast from 2026-04-18
09-43-26.webm](https://github.com/user-attachments/assets/e5df051a-db20-47a9-b62d-6f59a15eb17b)

---------

Co-authored-by: Vadim Peretokin <vadi2@users.noreply.github.com>
Co-authored-by: Zooka <136661366+ZookaOnGit@users.noreply.github.com>
2026-04-25 13:02:24 +02:00
Stephen Lyons
8b0e62b334
Fix: Clazy warnings part 1 - range-loop-detach (#9195)
#### Brief overview of PR changes/additions
One of a series of PRs, each addressing a type of issue reported by
Clazy.

This is the: "C+11 range-loop might detach Qt container
[clazy-range-loop-detach]" one.

#### Motivation for adding to Mudlet
Remove warnings detected by the Clazy tool - either when explicitly run
on the Mudlet code-base or detected by the background scanner/analyser
that Qt Creator offers.

#### Other info (issues closed, discussion etc)
There was over 100 of this particular item - some of them also could
have `const` applied to the iterator.

---------

Signed-off-by: Stephen Lyons <slysven@virginmedia.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-16 14:30:33 +01:00
Vadim Peretokin
6ea87c5649
improve: Auto-switch code editor theme with app appearance (#9167)
#### Brief overview of PR changes/additions
Auto-switch the code editor theme when toggling between light and dark
mode - finds counterpart themes by name (e.g. "Solarized light" <->
"Solarized dark"), falls back to stored preference, then defaults
(Monokai/Mudlet).

#### Motivation for adding to Mudlet
Follow-up to #8897 - the editor theme is a separate option that should
follow the app appearance.

#### Other info (issues closed, discussion etc)
https://github.com/Mudlet/Mudlet/pull/8897#issuecomment-4188760804

**Test case:** Open Preferences, switch Appearance to Dark - editor
theme should auto-switch. Pick a paired theme like "Seafoam Pastel
Dark", switch to Light - should find "Seafoam Pastel Light". Toggle back
- should restore dark variant.
2026-04-16 05:43:22 +00:00
Vadim Peretokin
d3396ee5bb
Fix: several resetProfile() bugs (#9083)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
1. Double reset guard (Host.cpp) — Added early return in
resetProfile_phase1() if mResetProfile is already true.
Without this, calling resetProfile() twice before the event loop
processes would schedule two phase2s, and the
second one would leak the Lua state (since initLuaGlobals() overwrites
pGlobalLua without closing the old one).
2. Anonymous event handlers cleared (Host.cpp) — Added
mAnonymousEventHandlerFunctions.clear() to
resetProfile_phase2(). This map was the only event-related map not
cleared during reset. After reset, it
contained stale function names pointing to functions that no longer
existed in the new Lua state, causing error
  spam when events were raised.
3. Media stopped on reset (Host.cpp + TMedia.h) — Added
mpMedia->stopAllMediaPlayers() call to
resetProfile_phase2(). Audio/music continued playing through profile
resets. Also moved stopAllMediaPlayers()
  from private to public in TMedia.h so Host can call it.

#### Motivation for adding to Mudlet
Bug fixes.
#### Other info (issues closed, discussion etc)
Builds on https://github.com/Mudlet/Mudlet/pull/9082, requires that to
be merged first.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-13 18:55:09 +02:00
Vadim Peretokin
5800be63d2
add: telnets:// link support for secure TLS connections (#9153)
#### Brief overview of PR changes/additions
Adds `telnets://` URI scheme support so clicking secure telnet links
opens Mudlet with TLS enabled.

#### Motivation for adding to Mudlet
MUD games advertising TLS connections can provide clickable `telnets://`
links that connect securely, complementing the existing `telnet://`
support.

#### Other info (issues closed, discussion etc)
Builds on #8601. Registers `telnets://` as a protocol handler on
Windows, Linux, and macOS. Uses IANA-assigned default port 992 for
`telnets://` when no port is specified. Honors TLS intent for both new
and existing profiles.

**Test case:** Run Mudlet with `telnets://example.com:992` as a CLI
argument and verify the profile is created with TLS enabled. Also verify
`telnet://` links still work unchanged.
2026-04-12 17:37:37 +02:00
Vadim Peretokin
8fa2f580e1
Fix: review fixes for font handling rework (#9135)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Fix a few things from
4c0e4b764e,
see below for details.
#### Motivation for adding to Mudlet
QA improvements.
#### Other info (issues closed, discussion etc)

1. getAndClearTempDisplayFont() called .value() without checking if the
optional was populated. The only caller is TMainConsole::TMainConsole()
at line 57. If setDisplayFont() in the Host constructor returns early at
line 1129 (because averageCharWidth() == 0 for the font),
mTempDisplayFont is never set. Calling .value() on an empty optional is
UB - in a no-exceptions build (which Mudlet uses), this is a crash with
no error message. The sibling method getDisplayFont() at line 4942
already had the correct guard pattern. We replicated it.

2. setFont(mDisplayFontDetails.makeFont(), true) at line 1606 calls
refreshView() internally (line 1598). Then setFontName() called
refreshView() again at line 1607. Each refreshView() sets fonts on both
TTextEdit panes, recalculates screen dimensions (updateScreenView()),
and forces a full repaint (forceUpdate()). Every font name change was
doing all of this twice.

3. mpHost is a QPointer<Host> that becomes null when the Host is
destroyed. The code dereferenced mpHost->mpConsole without checking
mpHost first. During profile teardown, TConsole can outlive Host
briefly. Other methods in the same class (e.g. raiseFontChangeEvent() at
line 1555) correctly guard with if (!mpHost).

4. Every other field in TFontAttributes had a default member initializer
except mStyleStrategy. Both existing constructors set it, so it's safe
today. But if anyone adds a third constructor (or a default constructor)
without remembering to set this field, it's an uninitialized read - UB.
Now defaults to static_cast<QFont::StyleStrategy>(QFont::NoAntialias |
QFont::PreferQuality), matching the bool constructor's false-case
behavior.

5. The CentralDebugConsole branch in changeColors() previously set font
properties and applied them to both panes. After the font rework it
became empty, but the comment "// No-op now?" with a question mark
signals the author wasn't sure if this was correct. It is correct - font
is now managed via QWidget::font() inheritance and propagated through
updateConsolesFont(). Replaced with a definitive explanation.

6. The setFont() comment said "This *should* be overridding the (void)
QWidget::setFont(const QFont&) method but doesn't seem to be...!" -
implying the intent was to override and something is broken. In reality,
QWidget::setFont is non-virtual so it can never be overridden. The extra
bool forceChange parameter also gives it a different signature. This is
intentional method hiding, not a failed override. The confused comment
could mislead someone into "fixing" it. Replaced with an accurate
explanation of why the hiding is needed.

7. Various typos fixed: overridding, constuctor, QOject, accessng,
releated, inconsistant, Accomodate, TFontDetails, and a stale mKerning
comment.
2026-04-11 08:01:19 +02:00
John McKisson
2f41aa2136
Fix: Better handling of package-supplied fonts during unloading/reloading of packages (#9115)
#### Brief overview of PR changes/additions
- Remove package loaded font from loadedFontPaths so it can be
re-registered if the package is updated/reinstalled
- Associate package fonts with profile name + packageName so unloading a
package in one profile (which removes its package loaded font) does not
remove the font from Mudlet which was being used by another profile
using the same package (which hasn't been removed)

#### Motivation for adding to Mudlet
Addresses issue https://github.com/Mudlet/Mudlet/issues/7532
This also addresses the issue where if more than one profile had a
package loaded that provided a font, and that package is unloaded and
updated/reinstalled, other profiles using that package would have the
font removed.

#### Other info (issues closed, discussion etc)
@SlySven We had spoken briefly about this before.. This seems to work
but I'd definitely like your eyes on it!

---------

Co-authored-by: Vadim Peretokin <vperetokin@hey.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-11 08:00:21 +02:00
Morquin
3036e1f3d7
improve: Give players full control over Discord Rich Presence (#9116)
## Summary

Players had no clear way to control what Discord shows about their
Mudlet activity. The old checkbox in the connection pane only gated
server GMCP data but didn't prevent Discord from showing "Playing
Mudlet", and the privacy controls were confusing. This PR replaces all
of that with three straightforward modes via radio buttons in Profile
Preferences > Chat:

- **Show full game details (if supported)** - full game integration with
server-provided presence (default)
- **Show Mudlet only** - only shows "playing Mudlet", game server is not
told about Discord
- **Disabled** - Discord shows nothing about Mudlet

Players pick the mode that matches their comfort level, and the existing
privacy checkboxes (hide detail, hide state, etc.) remain available in
Game details mode for finer control.

### What changed

- **Three-mode radio buttons** in Profile Preferences > Chat with a
two-column layout (modes on the left, privacy controls on the right),
replacing the old connection-pane checkbox
- **Server-origin tracking** so privacy checkboxes only gate data sent
by the game server - Lua API calls always pass through (only Disabled
mode blocks Lua entirely)
- **Mid-session mode switching** via dynamic GMCP negotiation
(Core.Supports.Add/Remove + External.Discord.Hello/Get)
- **Deferred RPC init** - Discord RPC now starts when a profile loads,
not on app launch
- **Username restriction improvements** - takes effect immediately,
case-insensitive (Discord usernames are lowercase-only since 2023),
shuts down RPC when mismatched
- **Shows logged-in Discord user** in preferences next to the
restriction field, with a tooltip explaining the desktop app requirement
when not connected
- **Presence fix** - empty string fields now send nullptr so Discord
hides them instead of showing blanks
- **Memory leak fix** - presence allocations are now freed in the
destructor regardless of RPC state

### Cleanup

- Removed obsolete discriminator field
(`mRequiredDiscordUserDiscriminator`) - Discord removed discriminators
in 2023
- Removed dead code (`getDiscordUserDetails()`, never called)
- Restored `Discord_ClearPresence` function pointer for potential future
use
- Use proper `Host::DiscordOptionFlags` types instead of raw `int`
(thanks @SlySven)

### Known quirks

- The "Hide timer" checkbox correctly omits timestamps from presence
data, but Discord's client starts its own activity timer for any
presence without a timestamp - this is Discord client behavior outside
our control.
- The "Hide large icon" setting clears the image key, but some Discord
clients fall back to the application's default icon instead of hiding it
entirely.

### Test plan

- [ ] Open Profile Preferences > Chat tab
- [ ] Switch between the three radio button modes and verify Discord
presence updates accordingly
- [ ] In Game details mode, toggle privacy checkboxes and verify fields
are hidden/shown
- [ ] Use Lua API (e.g. `setDiscordDetail("test")`) in Mudlet only mode
- should work. Try in Disabled mode - should fail with error
- [ ] Set a username restriction and verify presence clears immediately
if mismatched
- [ ] Run unit tests: `cd build && ./test/DiscordTest`
- [ ] Run functional tests: `cd build && ctest -R TDiscordModeTest -V`

Closes #6967. Supersedes #7438.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: SlySven <slysven@virginmedia.com>
2026-04-08 13:33:09 +02:00
Vadim Peretokin
05a4937abc
Fix: memory leaks in sound player, dialogs (#9142)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
Fixes memory leaks from playing sounds, opening the room exits dialog,
opening Preferences, right-clicking the console, connecting to MUDs with
compression, and viewing changelogs
#### Motivation for adding to Mudlet
Better memory management.
#### Other info (issues closed, discussion etc)

##### 3. TMediaPlayer destructor leaks TMediaPlaylist + QAudioOutput

```
playSoundFile() [TLuaInterpreterMedia.cpp:703]
  → TMedia::playMedia() [TMedia.cpp:47] → play() [TMedia.cpp:1485]
    → getMediaPlayer() [TMedia.cpp:1150]
      → make_shared<TMediaPlayer>(mpHost, mediaData) [TMedia.cpp:1193]
        → constructor: mPlaylist(new TMediaPlaylist)       ← LEAK #1 [TMedia.h:47]
        → constructor: setAudioOutput(new QAudioOutput())  ← LEAK #2 [TMedia.h:50]
    → updateMediaPlayerList() [TMedia.cpp:1102]
      → purgeStoppedMediaPlayers() [TMedia.cpp:1056] (when list > 25)
        → shared_ptr destroyed → ~TMediaPlayer() = default  ← NEVER DELETES [TMedia.h:52]
```

##### 6. dlgRoomExits TExit objects never freed

```
Right-click room → "Set exits..." [RoomContextMenuHandler.cpp:196]
  → T2DMap::slot_setExits() [T2DMap.cpp:4271]
    → new dlgRoomExits(...) [T2DMap.cpp:4278] + setAttribute(WA_DeleteOnClose) [T2DMap.cpp:4280]
      → init() [dlgRoomExits.cpp:279]
        → initExit() x12 [dlgRoomExits.cpp:1633-1775]
          → originalExits[dir] = makeExitFromControls(dir) [dlgRoomExits.cpp:1599]
            → new TExit() [dlgRoomExits.cpp:1968]  ← ALLOCATED
        → new TExit() per special exit [dlgRoomExits.cpp:1782] → originalSpecialExits[dir] [line 1848]
  User closes → ~dlgRoomExits() [dlgRoomExits.cpp:285] ← EMPTY, never deletes TExit objects
```

##### 7. dlgProfilePreferences QKeySequence* leak (no destructor)

```
mudlet::showOptionsDialog() [mudlet.cpp:3578]
  → new dlgProfilePreferences(this, pHost) [mudlet.cpp:3585]
  → setAttribute(WA_DeleteOnClose) [mudlet.cpp:3603]
    → constructor loop [dlgProfilePreferences.cpp:1394-1399]:
        new QKeySequence(*pHost->profileShortcuts.value(key)) [line 1398]
        currentShortcuts.insert(key, sequence) [line 1399]  ← 17 raw pointers stored
  User closes → WA_DeleteOnClose triggers delete
    → implicit ~dlgProfilePreferences() ← NO DESTRUCTOR EXISTS
      → QMap destroyed, pointer values NOT deleted
```

##### 10. ctelnet missing inflateEnd in destructor

```
Server sends WILL COMPRESS2 → processSocketData [ctelnet.cpp:4876-4880]
  → mNeedDecompression = true → initStreamDecompressor()
    → inflateInit(&mZstream) [ctelnet.cpp:4514]  ← allocates ~256KB

Connection drops → slot_socketDisconnected() [ctelnet.cpp:627]
  → mNeedDecompression = false [line 663]  ← NO inflateEnd()
  → reset() [line 664] ← NO inflateEnd()
  → ~cTelnet() [line 158] ← NO inflateEnd()
  (only inflateEnd is in decompressBuffer:4532, on Z_STREAM_END from server)
```

##### 11. ctelnet QNetworkReply leak on file error paths

```
slot_replyFinished() [ctelnet.cpp:1365]
  → reply->error() == NoError [line 1398]
    → file.open() fails [line 1400] → return ← LEAK (no reply->deleteLater())
    → file.commit() fails [line 1409] → return ← LEAK (no reply->deleteLater())
  (compare: error path at line 1393 correctly calls reply->deleteLater())
```

##### 15. TTextEdit QAction accumulation (5-10 per right-click)

```
Right-click on console → mouseReleaseEvent [TTextEdit.cpp:2287]
  → new QAction(tr("Copy"), this) [line 2374]      ← parented to TTextEdit, not QMenu
  → new QAction(tr("Copy HTML"), this) [line 2385]
  → new QAction(tr("Copy as image"), this) [line 2389]
  → new QAction(tr("Select all"), this) [line 2392]
  → new QAction(tr("Search on %1"), this) [line 2397]
  → (+ conditional actions at 2416, 2434, 2438, 2448, 2465)
  → popup = new QMenu(this) + WA_DeleteOnClose [line 2406-2407]
  → Menu closes → QMenu deleted, but QActions survive as TTextEdit children ← ACCUMULATE
```

##### 17. Updater changelog dialogs leaked

```
Help → Show changelog → Updater::showChangelog() [updater.cpp:202]
  → new dblsqd::UpdateDialog(feed, ...) ← no parent [line 202]
  → changelogDialog->show() [line 204] ← non-blocking, pointer lost at scope end
  (no WA_DeleteOnClose set; same at showFullChangelog:218)
```

##### 20. ModernGLWidget mTexCoordBuffer.destroy() missing

```
initializeGL() → setupBuffers() [modern_glwidget.cpp:146]
  → mTexCoordBuffer.create() [line 183]
~ModernGLWidget() → cleanup() [line 70]
  → mVertexBuffer.destroy()  [line 78] ✓
  → mColorBuffer.destroy()   [line 79] ✓
  → mNormalBuffer.destroy()  [line 80] ✓
  → mIndexBuffer.destroy()   [line 81] ✓
  → mInstanceBuffer.destroy() [line 82] ✓
  → mTexCoordBuffer.destroy() ← MISSING between lines 82 and 83
  → mVAO.destroy()           [line 83] ✓
```

##### 21. dlgComposer context menu + Hunspell suggestions

```
Right-click in composer → slot_contextMenu() [dlgComposer.cpp:226]
  → createStandardContextMenu() [line 228] ← returns new QMenu*, no parent
  → fillSpellCheckList() [line 233] → Hunspell_suggest() [line 331,335] ← allocates lists
  → popup->popup() [line 237] ← non-blocking, pointer lost
  ← QMenu leaked (no WA_DeleteOnClose, no deleteLater)
  ← If dismissed without selection: Hunspell_free_list() never called [only at line 468,472]
```

##### 22-24. Lower-impact (also in this branch)

```
dlgColorTrigger after exec() [dlgTriggerEditor.cpp:12469,12532]:
  → new dlgColorTrigger(this, ...) → exec() → no delete pD

Hunspell shared dictionary [mudlet.cpp:4768]:
  → mpHunspell_sharedDictionary = nullptr ← missing Hunspell_destroy() before

ExitsTreeWidget takeTopLevelItem [exitstreewidget.cpp:45]:
  → takeTopLevelItem(indexOfTopLevelItem(pItem)) ← return value discarded, never deleted
```

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-08 07:55:42 +02:00
Vadim Peretokin
62e7daaa63
Fix: crash when resetProfile() is called while downloads are in-progress (#9138)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions
esetProfile() now properly destoys the old Lua state, but it never
cancelled in-flight downloads. When a download was finished, it would
try and use the old Lua state that wasn't there anymore. Fix - cancel
downloads on resetProfile()


#### Motivation for adding to Mudlet
Fix crash introduced into development.
#### Other info (issues closed, discussion etc)
The new tests genuinely found a crashing issue!
2026-04-03 18:50:08 +02:00
Vadim Peretokin
3cc576ae3b
Fix: small memory leaks when closing/reopening profiles (#9110)
<!-- Keep the title short & concise so anyone non-technical can
understand it,
     the title appears in PTB changelogs -->
#### Brief overview of PR changes/additions

  1. Host::mStopWatchMap not cleaned in destructor

```
  createStopWatch() [TLuaInterpreterMudletObjects.cpp:256]
    → Host::createStopWatch() [Host.cpp:1410]
      → new stopWatch() [Host.cpp:1433] → mStopWatchMap.insert() [Host.cpp:1439]

  mudlet::closeHost() [mudlet.cpp:1970]
    → HostManager::deleteHost() [HostManager.cpp:31] → mHostPool.remove() [HostManager.cpp:42]
      → ~Host() [Host.cpp:418]
        → qDeleteAll(profileShortcuts)  ← PRESENT [Host.cpp:428]
        → qDeleteAll(mStopWatchMap)     ← MISSING
```

  2. Discord stale Host* map entries (dangling pointers)
```
  setDiscord*() functions → insert into QMap<Host*, ...> [discord.h:251-260] (10 maps)
  Profile close → mudlet::closeHost() [mudlet.cpp:1970]
    → mHostManager.deleteHost() [mudlet.cpp:2020] → Host destroyed
    ← Discord::resetData() NEVER called (not connected to signal_hostDestroyed)
    ← 10 maps retain entries keyed by dangling Host*
```
  3. MMCPServer + MMCPClient leak
```
  chatStartServer() → Host::initMMCPServer() [Host.cpp:3050]
    → mMMCPServer = new MMCPServer(this) [Host.cpp:3056]
      ← 'this' passed as arg only, NOT as QObject parent [MMCPServer.cpp:38]
      ← mMMCPServer is QPointer (non-owning) [Host.h:789]
  Profile close → ~Host() [Host.cpp:418] ← NEVER deletes mMMCPServer
    ← MMCPServer leaked with all connected MMCPClients
```
#### Motivation for adding to Mudlet
Addressing memory leaks
#### Other info (issues closed, discussion etc)
2026-03-26 06:55:19 +01:00
Mike Conley
2fa61a5de8
Fix: OSC 8 hyperlinks strip config/preset only when features are advertised (#9106)
#### Brief overview of PR changes/additions
OSC 8 hyperlinks now only strip `config` and `preset` query parameters
from web URLs when the corresponding features are actually advertised
via NEW-ENVIRON. Also fixes stripping when `=` is percent-encoded as
`%3D`.

#### Motivation for adding to Mudlet
Improves protocol correctness for the MUD server community. Servers
using only basic `send:` and `prompt:` schemes no longer have their web
URLs unexpectedly modified.

#### Other info (issues closed, discussion etc)
- Regression fix from #9073 for encoded `=` handling
- Added tests for encoded `=` variants
- `config` stripped when any config-using feature enabled (STYLE_BASIC,
STYLE_STATES, TOOLTIP, MENU, COMPACT, VISIBILITY, SELECTION, SPOILER,
DISABLED)
- `preset` stripped only when PRESETS feature is enabled

---

**Before**

<img width="467" height="119" alt="Screenshot 2026-03-22 at 2 07 41 AM"
src="https://github.com/user-attachments/assets/5662a8c5-2c9f-415c-9a49-31b653c9ed7e"
/>

**After**

<img width="497" height="126" alt="Screenshot 2026-03-22 at 2 42 42 AM"
src="https://github.com/user-attachments/assets/15d6afd5-a9e0-4faf-ab2f-31251ea3e633"
/>
2026-03-24 11:52:33 +00:00
Mike Conley
5b4871d54e
Improve: Add smooth pulsing effect for blinking text (#9104)
#### Brief overview of PR changes/additions

Adds a smooth pulsing animation for blinking text (SGR codes 5 and 6)
controlled by a new "Enable blinking text" checkbox in Settings >
Accessibility. When disabled, blinking text is shown in italics instead.
The pulse effect is also applied to HTML log exports using matching CSS
animations.

#### Motivation for adding to Mudlet

Blinking text is a standard terminal feature that MUD servers can send.
This adds an accessible, WCAG-compliant way to display it — the smooth
pulse avoids harsh on/off flashing while the checkbox gives users full
control to disable it.

#### Other info (issues closed, discussion etc)

- Lua API: `setConfig("enableBlinkText", true/false)` and
`getConfig("enableBlinkText")`
- WCAG 2.3.1 compliant: slow blink at 0.5 Hz, fast blink at 1 Hz (both
well under 3 flashes/second limit), with opacity floor of 0.4

---


https://github.com/user-attachments/assets/292aa7ef-0239-44dd-9d9c-8e5079a7e6e4
2026-03-24 12:30:08 +01:00
John McKisson
1e9ecebce1
Fix: Refactor and fix logic handling chat name change from GUI preferences dialog (#9025)
#### Brief overview of PR changes/additions

Refactor mChatName out of MMCPServer as the value is stored in Host
already (for profile saving), this simplifies syncing between the two.
Fix signaling so the connected clients are notified when chat name is
changed via GUI.
We're left with a double setText in
dlgProfilePreferences::slot_setMMCPChatName but this should be OK as
setText does not re-trigger the editingFinished signal.

#### Motivation for adding to Mudlet

#### Other info (issues closed, discussion etc)
2026-03-23 16:24:55 +01:00