Commit graph

3964 commits

Author SHA1 Message Date
tooomm
5dc5390880
Fix and rework client updater logic (#7038)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* first version

* combined logic + better regex

* Simplify, we only support 64bit, installer prevents wrong installs

* lint + fix Linux versions (missing declaration)

* update regex

* improve/add comments

* comments

* ReleaseChannel: parse non-numeric OS versions on Windows Server

QSysInfo::productVersion() returns "Server 2022" on Windows Server, so
splitting on '.' and calling toInt() made the version unparseable and
disabled the in-client updater there. Extract the first numeric token
instead; Server 2022 becomes 2022 and matches the Win10 assets.

* ReleaseChannel: don't offer 64-bit assets on 32-bit Windows

The old downloadMatchesCurrentOS() had an explicit 32-bit branch that
only accepted "32bit" assets. Restore that guard: 32-bit Windows builds
return no match instead of being offered the 64-bit Win10 installer that
won't run.

* ReleaseChannel: only query the OS version on updater platforms

The in-client updater only ships assets for macOS and Windows, but the
host system version was still being resolved on every platform, including
Linux where it is never used. Compute it only under the Q_OS_MACOS /
Q_OS_WIN guards.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-03 17:57:03 +02:00
BruebachL
0934380a5c
[Translations] Load Qt's own translations and build ours with the binary (#7384)
* [Translations] Load Qt's own translations and build ours with the binary

Three separate bugs kept translations from working.

installNewTranslator() loaded the application translation into
qtTranslator. QTranslator discards its previous contents on every load, so
that wiped the qt_<lang>.qm loaded one line earlier and installed an empty
translator instead. Qt's own strings were never translated on any platform.
Give each file its own translator.

QLibraryInfo::path(TranslationsPath) was the only directory searched for
Qt's translations. A relocated Qt can report a path that does not exist: the
KDE Flatpak runtime reports /usr/translations while its files live in
/usr/share/qt6/translations. Probe the usual install layouts and only hand
back the ones that exist.

The .qm files were attached to the default build target only, so building
the executable on its own left it without any translations and the language
selector empty. Attach them to the target as well.

Move the shared lookup into TranslationLoader so client and Oracle cannot
drift again, and have it report a missing translation at debug level. An
unset language is the default for English, which has no file to begin with,
and warning about it only added noise to every fresh debug log.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>

* [Tests] Define main() in translation_loader_test

Without a GTest installation the fallback in tests/CMakeLists.txt links
only gtest, not gtest_main, so the test has to bring its own main(). Every
other test already does; only this one relied on the distribution package
and failed to link on CI.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>

* [Tests] Put the translation loader fixture next to the test executable

A multi-config generator (MSBuild, Xcode) writes the executable to a
per-config subdirectory while the .qm stays in the current binary
directory, so applicationDirPath() never saw the fixture and
ABuildTreeFindsTheTranslationsNextToTheExecutable failed on Windows.

* [Translations] Depend on Qt's lrelease target and skip the loader test without lrelease

Attaching the .qm custom commands to a second custom target races with
Qt's own <Project>_lrelease target on MSBuild/Makefiles and is rejected
by Xcode, so depend on Qt's target instead. Only build the translation
loader test when Qt6::lrelease is available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: ZeldaZach <zahalpern+github@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:04:30 +02:00
BruebachL
3aba1ef929
[Settings] Back up the settings folder before migrating (#7386)
* [Settings] Back up the settings folder before migrating

The per-file settings migration only preserved global.ini, as
global.ini.old. Everything else the migrations write was overwritten in
place with no way back: the legacy migration and the 17 per-domain
migrateXxxSettings functions all write straight into the per-domain .ini
files, so any of them that got a key mapping wrong silently replaced a
working file. The re-migration path is the sharpest case, because
migrateSettingsFromGlobalIni() runs again whenever an older build wrote
non-sentinel keys back into global.ini, and those values then land on
top of per-domain files the user had already edited.

Snapshot the whole settings folder before either migration runs, into a
timestamped sibling settings-backup-<yyyyMMdd-HHmmss>. A sibling rather
than a child, so a backup is never copied into the next backup and the
"Open Settings Folder" action keeps showing only live files. Keep every
snapshot; settings files are kilobytes, so growth is not a concern.

SettingsMigration::isMigrationPending() gates the copy so a normal start
creates nothing. It reuses the same condition migrateSettingsFromGlobalIni()
uses to decide whether it has work, factored into a shared helper so the
two cannot drift, and it ignores the legacy migration on an empty profile
where that migration only writes its sentinel. The backup itself skips a
folder whose files are all empty, which covers a fresh profile whose
global.ini was created but never written.

A failed copy logs a warning and returns an empty path rather than
aborting the migration, since a full disk or an unwritable data folder
must not leave the client unable to start. global.ini.old still works as
the single-file escape hatch.

Document the folder layout, the automatic backups, and the fact that
migrations only run forwards, so users know to copy the settings folder
before downgrading to a version that cannot read the per-group files.

* [Client] Match the desktop file name case for the portal app ID

QGuiApplication::setDesktopFileName() was given "Cockatrice"/"Oracle"
while the installed desktop entries are cockatrice.desktop and
oracle.desktop. Linux paths are case-sensitive, so xdg-desktop-portal
failed the app-info lookup for "Cockatrice" and Qt warned on every
QDesktopServices call that it could not register with the host portal.
The same string becomes the xdg-shell appId, so the window icon lookup
failed on Wayland for the same reason.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-03 14:59:58 +02:00
BruebachL
b8b81170bf
[Client] Show card names in the game log and [card] tags in the chosen card language (#7387)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
The game log translates its sentences, zone names and counter names, but every
card name was rendered as the raw canonical name straight from the server.
The card board and the card info text already went through
CardLocalization::displayName(), so the log was the odd one out.

cardLink() used one string for both the card:// href and the visible label,
which left no room for a localized label. Split the two: the href keeps the
canonical name because ChatView::mousePressEvent feeds it straight to
showCardInfoPopup for lookup, while the label goes through the display
language. Same split for [card] chat tags in ChatView::appendCardTag.

Sanitize the name while building the link. logUndoDraw already did this by
hand and had drifted into its own non-italic copy of cardLink(); route it back
through the helper.

Add CardLocalization::displayNameFor() for callers that only hold a canonical
name and need no CardInfoPtr of their own.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-03 13:47:07 +02:00
BruebachL
014983b5bc
[Theme] Carry Default theme directory customizations over to System (#7385)
* [Theme] Carry Default theme directory customizations over to System

The Default -> System rename rewrote the stored theme name but left the
user profile's themes/Default directory behind, so anyone who had
customized zone graphics, stylesheets or palettes there silently lost them.

Migrate the directory alongside the name. Rename it when System does not
exist yet, otherwise merge file by file without overwriting what System
already has, then drop the legacy directory. Users who already ran the
rename get their customizations back on the next start, and a hand-made
Default folder of a user on another theme is left untouched.

* [Tests] Give theme_migration_test an explicit main and gate it on WITH_CLIENT

The build machines that cannot find GTest fall back to the downloaded
googletest, which links the bare gtest library without gtest_main. Every
other test defines its own main() for that reason; this one did not, so
Debian 13, Fedora 44 and Ubuntu 26.04 failed to link it.

The test compiles client sources, so -DTEST=1 -DWITH_CLIENT=0 has no way
to build it. Gate the subdirectory on WITH_CLIENT like the other
client-source test.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-03 13:43:25 +02:00
BruebachL
add65caae5
[Client] Hand off single-instance payloads outside the readyRead signal (#7373)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Client] Hand off single-instance payloads outside the readyRead signal

Opening a cockatrice:// link from outside a running client showed the
"Join game ...?" confirmation and then crashed on both answers. The
join reached the server first, so the other players saw the user join
and immediately disconnect.

SingleInstanceManager::handleNewConnection() emitted filesReceived from
inside the forwarding socket's readyRead lambda and only disconnected
afterwards. A joingame link makes the handler show a modal QMessageBox,
which runs a nested event loop. The sending instance exits as soon as
it has the acknowledgment, so the socket was disconnected and
deleteLater'd inside that loop: it died in the middle of its own
readyRead signal, and both this lambda and Qt's signal dispatch kept
using the freed memory.

Disconnect right after the acknowledgment and emit filesReceived
through a queued call, so no handler and no nested event loop ever runs
inside the socket's signal. Guarding the disconnect call alone is not
enough, since Qt still touches the socket after the lambda returns.

Add a regression test that reproduces the nested-event-loop hand-off.

* [Tests] Apply cmake-format to the single-instance test wiring

* [Client] Defer the single-instance socket teardown past the readyRead frame

The forwarded payload is emitted synchronously from inside the forwarding
socket's readyRead, which is what lets the modal "Join game?" confirmation
appear. A handler can spin a nested event loop, and the sending instance
exits as soon as it has the ACK, so the socket is disconnected and
deleteLater'd while that loop runs. Control then returns to this frame with
the socket already destroyed, and the inline disconnectFromServer() touched
freed memory.

Queue the teardown instead and guard it with a QPointer taken before the
handlers run -- building the guard afterwards is itself a use-after-free,
since QPointer dereferences the pointer it is constructed from. The payload
still reaches the handlers synchronously, so the confirmation is unchanged.

The regression test covers both halves: the handlers must run while the
socket is still connected, and the socket must not be torn down until the
readyRead frame has returned.

* Lint.

* [Client] Only delete the single-instance socket outside the readyRead frame

The auto-delete on `disconnected` was the remaining use-after-free. A
handler may spin a nested event loop (the modal "Join game?" box), the
sending instance exits as soon as it has the ACK, and so the peer closes
while that loop runs. deleteLater() issued from inside such a loop is not
deferred to the outer loop: Qt frees the object as soon as the nested loop
unwinds, which is still inside this readyRead emission, so Qt's own signal
dispatch and the handler lambda both keep using a freed socket. When the
notification happens to arrive late the bug hides, which is why this only
reproduced on some platforms.

Drop the auto-delete and let the queued teardown own the socket's lifetime:
it disconnects and then deletes, once the frame has unwound. The QPointer
guard was necessary but not sufficient -- it stopped us dereferencing a
dead pointer, not the socket being freed underneath the frame.

The test waits on the socket's destroyed signal instead of polling, so the
teardown assertion does not depend on platform event timing.

* [Client] Do not queue the single-instance socket teardown

The previous commit moved the deleteLater() off the disconnected signal
and into a queued invocation, believing that "queued" meant "after the
readyRead frame returns". It does not. A queued call is delivered by
whichever event loop is running, and the modal "Join game?" box runs a
nested one -- so the teardown was still executed inside the readyRead
emission, which is exactly the use-after-free it claimed to prevent.

Queueing was never necessary. Because nothing connects deleted/close to
the socket's disconnected signal any more, no nested loop can free it
while the handlers run, and emit filesReceived() is synchronous: once it
returns, the frame has unwound and the socket can be closed and deleted
inline. The QPointer stays, covering the one case that remains -- the
primary shutting down while the confirmation box is open, since
QLocalServer destroys its pending connections.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 21:19:02 +02:00
BruebachL
4ce2e8c3d3
[UserList] Fix join/spectate from the profile hover card doing nothing (#7383)
The games list in the profile hover card offered "Join game" and
"Spectate", but UserInfoPopup::joinGameRequested was only forwarded to an
identical UserListWidget::joinGameRequested signal that nothing connected
to. Clicking either entry sent no command, opened no tab and showed no
error.

Route the request to TabSupervisor, which owns the join flow. The popup's
games list spans every room the user is in, so the target room may not be
open yet; IntentJoinServerGame already joins the room first and retries,
which is why this goes through the intent rather than straight to
GameSelector.

Extract the intent construction that joinReportGame already had into a
shared joinGameByIntent helper instead of duplicating it.

Also stop disabling "Join game" for games that are full or already
running. GameSelector::joinGame() offers to join as a spectator in that
case, so a greyed-out entry read as a dead button; GameSelector's own
context menu never disables it either.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 18:57:07 +02:00
BruebachL
750d5b3c21
[Client] Add option to use game time in game log timestamps (#7265)
* [Client] Add option to use game time in game log timestamps (#2202)

Game log timestamps showed the local clock time, which is meaningless in
a replay or in a log read after the fact — nobody can tell when a play
happened relative to the game.

Add a "Use game time instead of local time in game logs" setting. When
enabled, the message log stamps entries with the elapsed game time
(HH:MM:SS since the game started); regular chat views keep the local
clock.

* [Game] Move formatElapsedTime helper out of public slots (#2202)

The message log needs the HH:MM:SS formatter used by the game timer, but
it lives in GameState's public slots, tying a pure formatting helper to
the signal-slot machinery.

Move formatElapsedTime into a public section of GameState so the log can
call it directly, and add the missing trailing newline to game_state.cpp.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 17:30:48 +02:00
RickyRister
ffcb9094ca
[Replay] Fix cards accumulating in zone view when rewinding (#7357)
* [Replay] Fix cards accumulating in zone view when rewinding

* rename method
2026-10-02 17:17:34 +02:00
BruebachL
ecd955bc60
[Installer] Wait on the launching process so auto-updates cannot write over a mixed Qt runtime (#7372)
* [Installer] Abort instead of installing over leftover runtime files

The silent upgrade could leave a mix of old and new Qt DLLs next to the new
executables, which is what makes the next start fail with "The procedure entry
point X could not be located in the dynamic link library ...Qt6QuickWidgets.dll"
or "The code execution cannot proceed because Qt6Multimedia.dll was not found".

The check that was supposed to catch this probed only Qt6Core.dll and
Qt6Network.dll, so it was blind to the two files the reports name. It also only
warned and then installed anyway, so a caught problem was still compounded.

- Scan every file the purge targets with FindFirst, which takes the same
  wildcards the Delete/RMDir calls use, instead of two hardcoded names
- Widen IsExistingCockatriceInstall from Qt6Core.dll to Qt6*.dll so a directory
  left holding only some Qt modules is still recognised as an install
- Gate the scan on that, so a first-time install into a directory that happens to
  hold unrelated Qt5 DLLs is not turned into an abort
- Abort with a non-zero exit code instead of warning, so the new payload is
  never written next to files of the previous version
- Skip the message box on an unattended /S install, which would block on it
  forever, matching the two existing abort sites

* [Installer] Cancel a silent upgrade when the running-instance guard fails

The running instance guard is what stands between a locked install directory
and the mixed Qt DLL set that breaks the next start, but it failed open in two
places and let the install carry on anyway:

- powershell.exe could not be started: the check warned and returned
- the process lookup answered "could not determine": WaitForAppToClose returned
  on $R0 != 1, so "unknown" was treated exactly like "not running"

On a silent upgrade there is nobody at the keyboard to close the applications in
response to that warning, which is precisely the state in which the launcher was
still holding its Qt DLLs mapped.

- Treat "could not determine" as its own state rather than folding it into "not
  running", in all three places WaitForAppToClose inspects the result
- Cancel a silent upgrade in both cases; keep an interactive install on the
  existing warn-and-continue path, since refusing there would leave an installer
  that can never complete and the stale-runtime check still applies
- Route every abort through one AbortInstall so the dialog-vs-silent decision
  lives in a single place, and stop the two pre-existing warning dialogs that a
  /S install without /R would have blocked on forever

* [Installer] Wait on the process that launched the update installer

The in-app updater starts the installer and only then quits itself, so the
installer has to decide whether the launching process is still alive while it is
running. It did that by polling powershell.exe for an image name under the
install directory, which is a proxy for the real question - is the process that
started me still holding its Qt DLLs mapped - and a proxy that can answer "not
running" or "could not determine" while the process is very much alive. Either
answer let the install carry on into the mixed Qt DLL set.

Take /PID= from the command line and wait on that process handle directly:

- OpenProcess with PROCESS_SYNCHRONIZE only, which is all that is needed to wait
  and the least likely to be refused for an unrelated reason
- WaitForSingleObject for the same 60 s the name based check allows, so a client
  slow to flush its card database is not squeezed
- Treat an unopenable handle as "already exited", which is what it means for a
  dead process
- Cancel the upgrade if the launcher has not exited, rather than replacing files
  it is still using

Run it before EnsureAppsNotRunning, not after: it needs no powershell.exe, so it
is the one check that still works on a machine where the process lookup cannot
run, and it is what lets that case keep upgrading instead of being cancelled by
what was only ever a proxy for it. Keep an older client, which passes no /PID=,
on the existing path.

An older installer ignores /PID=, and /D= stays the last parameter either way.

* [Client] Pass our process id to the update installer

The updater starts the installer and only then quits itself, so the installer
begins replacing files while this process still has every Qt runtime DLL mapped,
and a mapped DLL cannot be deleted. It has to work out that it must wait, and
until now all it had was its own image name to go on.

Pass /PID= so it can wait on this process directly instead of guessing, and
cancel the update rather than replace files this process is still using. /D=
stays the last parameter, which NSIS requires.

* [CI] Fail the build when the installer is missing a Qt runtime DLL

The Windows payload is assembled by globbing every *.dll out of the build output
directories, so nothing but luck decides whether a module the client links
statically - Qt6Multimedia.dll, Qt6QuickWidgets.dll - reaches the installer at
all. A module that is left out is invisible until a user installs an update and
the client then refuses to start, with "The code execution cannot proceed because
Qt6Multimedia.dll was not found" or a missing procedure entry point in a
Qt6QuickWidgets.dll carried over from the previous version.

Derive the required list from SERVATRICE_QT_MODULES, COCKATRICE_QT_MODULES and
ORACLE_QT_MODULES, the same module lists the targets are actually built against,
so it cannot drift from what the binaries need, and write it next to the CMake
cache as required-qt-runtime.txt. Add the platform plugin, without which the
client cannot start at all.

Assert it in the existing 7-Zip pass that already rejects build-tree artifacts,
comparing exact relative paths so a same-named file in the wrong directory does
not satisfy a requirement, and normalizing the archive's own separators so it
does not matter which one NSIS chose. Missing manifest, missing entry or missing
7-Zip-independent failures all stop the build rather than warn, which is the
whole point: the previous check already failed hard for artifacts, and a warning
here is what let a broken package ship.

* [Client] Point the Windows qt.conf translations path at the real one

The Windows qt.conf carried Translations = Resources/translations, copied from
the macOS bundle block, but the Windows install has no Resources directory: the
.qm files are installed to COCKATRICE_WIN32_QM_INSTALL_DIR, which is
translations, and main.cpp looks for them at applicationDirPath() +
"/translations". Leave the macOS value alone, which is correct for it.

* [Installer] Close the /? help string so the installer compiles again

The last line of the continued MessageBox string ended in $\n\" instead of $\n".
NSIS reads \" as an escaped quote, so the string never terminated and makensis
rejected the script, which took the CPack packaging step down with it. The
Windows build failed on "Problem running NSIS command" before ever reaching the
new required-runtime check.

* [Installer] Give FindFirst the two arguments it takes

FindFirst takes the pattern and the output variable and leaves the search
handle in $0. There is no handle argument, and FindNext taking only a variable
is the giveaway: the handle has to live somewhere implicit, and it is $0.

The call passed three arguments, so makensis rejected the script and the CPack
packaging step failed with "Problem running NSIS command" before ever reaching
the required-runtime check.

This was not only a compile error. The handle variable was never populated, so
the stale-runtime check that commit 844a1a9b added could never see a match: it
probed $StaleFindHandle, which stayed empty, and the widened wildcard scan over
the purge set was a no-op. Pass the pattern, test $0, and close $0. No FindNext
is needed, because $R0 is a yes/no flag rather than a list.

Drops the now unused StaleFindHandle Var.

* [CI] Dump the NSIS compile log when packaging fails

CPack reports a failed makensis run as "Problem running NSIS command" plus a
path to NSISOutput.log, and that log exists only on the runner, so the actual
compile error never reaches the workflow output. An installer script that does
not compile is therefore undiagnosable from CI alone, which is exactly how a
three-argument FindFirst cost two build cycles here.

* [CI] Run the NSIS log dump under bash

The dump step used the default pwsh shell, where `find` resolves to Windows'
text-search find.exe rather than a file finder, so it died with "FIND:
Parameter format not correct" instead of printing anything. Pin shell: bash.

* [Installer] Test the error flag with LogicLib's ${Errors} condition

makensis rejected the script with "macro _If requires 4 parameter(s), passed
2" at the ClearErrors/OpenProcess pair. A ${If} condition cannot be a bare
$Var: LogicLib needs either a comparison or one of its flag macros, and a lone
$Errors splits into too few arguments for _If. ${Errors} is the documented flag
condition and is the same mechanism as the ${Silent} test already used here.

The dump step earned its keep - this took one run to locate rather than another
round of guessing.

* [Installer] Give FindFirst its handle back and stop blocking silent aborts

makensis: "FindFirst expects 3 parameters, got 2. Usage: FindFirst
$(user_var: handle output) $(user_var: filename output) filespec".

The previous commit was wrong about this. FindFirst does take the handle
explicitly, and FindNext finds it in $0 as a side effect of the FindFirst that
set it, which is why FindNext takes only a variable. The three-argument form the
stale-runtime check was written with was correct all along, and the two-argument
form broke it. Restored, along with Var StaleFindHandle.

That same commit inverted the interactive check in both abort paths.
`${If} $ReinstallMode = 1` with `${OrIfNot} ${Silent}` shows the message box
exactly when the install is the silent /R upgrade - the one case the comment
says must never block, since nothing will dismiss it and the installer hangs
until it is killed. Replaced with `${IfNot} ${Silent}`, so the box appears only
for an interactive install and a silent abort goes straight to SetErrorLevel.

Audited every construct added by this branch against master, which compiles:
all 9 LogicLib condition shapes have a master precedent, FindFirst is the only
FindFirst and is back to 3 arguments, Function/Section and block depth balance,
and the 12 lines with an odd number of quotes are the same pre-existing string
continuations master has.

* [CI] Strip the CR 7-Zip leaves on its paths before comparing them

The installer check reported all 14 required files missing, including
Qt6Core-era basics an 82 MB package obviously contains, so the payload was fine
and the comparison was broken. 7-Zip writes CRLF on Windows, sed 's/^Path = //p'
kept the \r on every line, and grep -qxF matches whole lines - so every entry
carried a trailing \r and nothing matched. The build-tree artifact check next to
it uses an unanchored grep -E and was unaffected, which is what made the two
checks disagree.

Reproduced locally against a 7z archive with CRLF injected: the old pipeline
matches nothing, the new one finds the present files and reports only the
genuinely absent one.

Also match case-insensitively. That is how the installed tree behaves - the
uninstaller clears both $INSTDIR\plugins and $INSTDIR\Plugins - so a
Plugins vs plugins difference in the archive is not a missing file.

* [CI] Print both sides of the installer path comparison, and strip its prefixes

The check still reported all 14 files missing after the CR fix, so the CR was
not the cause and the real difference between 7-Zip's output and the manifest
is still unknown. Inferring it again is what produced two wrong fixes in a row,
so on failure the check now prints the required list and the first 40 paths
7-Zip lists. A bare "missing" line cannot distinguish a missing file from a
misread listing, and twice it was the latter.

Also strip $INSTDIR/, $OUTDIR/, $PLUGINSDIR/ and ./ prefixes from the listed
paths. Written as separate -e expressions rather than one alternation: with |
as the sed delimiter an escaped \| is a literal pipe, so the alternation
version silently matched nothing at all.

Verified against a 7z archive fed through seven path shapes - plain, Windows
backslashes with CRLF, each prefix, lowercase plugins, and all of them combined
- each reporting only the file genuinely absent from the archive.

* [CI] Strip CR from the manifest side of the installer path check

The dump added last run settled it: 7-Zip listed 209 paths including Qt6Gui.dll,
Qt6Multimedia.dll and Plugins/platforms/qwindows.dll verbatim, as plain relative
paths with no prefix - so the archive side was already clean and the previous
fix was right to strip CR there but not sufficient.

The trailing CR was on the other side. CMake writes required-qt-runtime.txt
with the platform line ending, so on Windows $entry arrived as
"Qt6Gui.dll\r", which cannot whole-line match a clean "Qt6Gui.dll". Every
entry then read as missing while the listing held it, which is precisely what
the two sides of the dump showed.

Reproduced locally with a CRLF manifest against a 7z archive: without the strip
every entry is reported missing, with it only the genuinely absent file is.
Checked across plain paths, Windows backslashes with CRLF, $INSTDIR, $OUTDIR,
$PLUGINSDIR, ./ and / prefixes.

The literal $INSTDIR-style prefixes are written as [$] bracket expressions so
shellcheck does not read them as unexpanded variables; that clears the SC2016
that failed the format job, without a disable directive.

* [CI] Reword the bracket-expression comment so the linter stops parsing it

A comment whose first word is shellcheck is read as a directive, so
"# shellcheck does not read these as unexpanded shell variables" was parsed as
a directive named "does" and failed with SC1073 and SC1072 - which cascaded into
the SC1009 on the else clause above it. The SC2016 it was meant to document is
already gone now that the prefixes are written as [$] bracket expressions, so
the note only had to stop looking like a directive.

* [CI] Indent the diagnostic path lists without sed, and undo comment drift

SC2001 asks for parameter expansion instead of sed 's/^/  /'. Echoing
${manifest//$'\n'/$'\n  '} prepends the two spaces directly and keeps the
diagnostic block readable. Both occurrences are converted; only the one reading
from a here-string was reported, but the piped one has the same shape.

Also restore master's wording of the build-tree artifact comment. An earlier
edit inserted "in " ("not the install)" became "not in the install)"). It has no
functional effect, but it was an unintended change to a line this branch has no
reason to touch, and .ci/compile.sh now differs from master only by additions.

* [CI] Ship the Qt module runtimes the Windows package check requires

The installer check reports Qt6Concurrent.dll and Qt6ShaderTools.dll missing
from the Windows package. Both are genuinely absent, not another comparison
artifact - the manifest now matches 12 of 14 entries and the dual-list
diagnostic shows the other two are not in the archive at all.

Nothing deploys them. fixup_bundle copies only the DLLs it finds in
Cockatrice.exe's import table, and the *.dll glob only sees what was already
copied next to the executable. Qt6::Concurrent is linked but never imported,
because QtConcurrent::run and QFutureWatcher are header-only in Qt 6, so the
linker drops the import; Qt6::ShaderTools is skipped the same way. The package
has shipped without them and the client still starts, but that is luck rather
than intent, and required-qt-runtime.txt asks for both.

Install the runtime of every linked module explicitly so the installer contains
what the check demands. An unused DLL costs a few hundred kilobytes; a missing
one is what stops the client starting after an update, and stays invisible until
then.

Derive both the manifest and the install list from the imported target's TYPE,
so a statically linked Qt module - which ships no runtime at all - is skipped by
each and the two can never disagree. Scope the manifest to the client rather
than the union across server, client and Oracle: only the client is packaged on
Windows, and the other two module lists are subsets of the client's, so this
changes nothing today.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 15:15:29 +02:00
BruebachL
1365f3cb49
[Avatar] Cap user avatar upload resolution at 1024x1024 (#7353)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
* [Avatar] Cap user avatar upload resolution at 1024x1024

* Remove stylesheet.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-27 21:38:50 +02:00
BruebachL
2632f8a040
[Client/Server] Keep non-deck files out of the deck storage (#7352)
The local deck and replay browsers list every file in the folder, so a
stray file (e.g. a PNG screenshot of a deck) can be parsed as a garbage
plaintext deck, opened in the editor, and uploaded. The server also
accepts oversized deck payloads with only silent truncation.

Hide files that are not in a supported deck/replay format in the two
QFileSystemModel views (directories stay visible), skip them when opening,
reject them when uploading, and make the server reject deck uploads larger
than MAX_FILE_LENGTH instead of truncating them.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-27 21:32:49 +02:00
BruebachL
fa8220df3c
[Client] Exit deterministically when launching the update installer (#7309)
* [Client] Exit deterministically when launching the update installer

* [Client] Only quit when the main window close is accepted

Wait for MainWindow::close() to be accepted before quitting after the
update installer is launched. When the close is vetoed (a running card DB
update, open games, or an unsaved deck), keep running and tell the user
the installer is already waiting, instead of exiting over their answer.

Also fix the comment so it does not claim settings are saved on the vetoed
path.

* [Client] Only quit on update when the shutdown actually ran

MainWindow::closeEvent has a static re-entrancy guard that returns early on a
second close event, leaving it in its default accepted state. DlgUpdate reached
from a nested event loop while a shutdown prompt was up could then get close()
== true with no shutdown work done (no settings flush, no tab shutdown) and tear
down the process behind an unanswered prompt.

Add MainWindow::closeForUpdate(), which reports false when a close is already in
progress or was vetoed, and gate the update-exit on it.

* [Client] Use QCoreApplication::exit() to leave on update

QCoreApplication::quit() only asks the application to quit and can be
interrupted if a top-level window refuses its close event. On the update path
the process must actually leave so its Qt DLLs are unlocked before the installer
starts replacing files - apply the exit(0) variant both on the accepted-close
path and the fallback where no MainWindow was ever closed.

* [Client] Warn that the update installer force-closes within a minute

Telling the user Cockatrice just 'stays open for now' is misleading: the
installer launched by #7308 waits only about 60 s for a graceful close before it
force-terminates the process, which would lose unsaved work. State the deadline
and tell the user to save and close before then.

* [Client] Warn that a vetoed update is cancelled, not forced

The warning on the veto path promised the opposite of what the installer
does: WaitForAppToClose in #7308 never force-closes cockatrice.exe
($AllowForceClose is 0), it aborts the update after 60 s so no unsaved data
is lost. A user who is told Cockatrice will be terminated in a minute hurries
to close it, only to find the update already given up.

State the real outcome instead: the installer waits about a minute for a
close, and the update is cancelled if that does not happen.

* [Client] Warn about a waiting installer without blocking it

CloseMatchingApps in #7308 sends the WM_CLOSE once and the wait loop only
polls IsAppRunning afterwards, but QMessageBox::warning is application modal
and QGuiApplicationPrivate::processCloseEvent drops spontaneous close events
for windows blocked by a modal widget. The veto path therefore swallowed the
installer's one and only request, so even a user who resolved the blocker and
closed the app still ended in the 60 s timeout.

Move the warning into warnInstallerIsWaiting() and show it modeless, so the
main window stays able to answer the installer while the message is up.

* [Client] Close the parent on the no-MainWindow update fallback

The fallback branch exits the process without closing anything, so it skips
the settings flush and tab shutdown this change exists to get in front of the
installer - the opposite of the fix if a call site ever stops passing a
MainWindow.

Close the parent widget first, like the code before this change did, and only
then leave unconditionally: the parent is the one that knows how to shut down
gracefully, and the deterministic exit is what the installer needs.

* Add override.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-27 20:05:41 +02:00
patricktsullivan
7c9dc303f8
[Game] Make 'Put top cards on stack until...' history case-sensitive (#7358)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
2026-09-27 09:50:05 +02:00
BruebachL
e9cd9453ea
[Startup] Run the onboarding wizard before the first-run set dialog (#7349)
On a clean install, checkUnknownSets() fired before the onboarding wizard,
so the legacy 'all the sets have been enabled' welcome and the Manage Sets
dialog appeared first - with no card data there are no sets to manage yet.
Defer the set check until after the wizard closes, suppress its dialogs
while onboarding runs, and enable newly downloaded sets silently instead.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-25 08:32:03 +02:00
BruebachL
bc26e19564
[DeckEditor] Highlight card name in VDE quick search after adding (#7350)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-25 08:31:38 +02:00
BruebachL
a9b38ec0e1
[UserList] Sort developers below judges (#7347)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-24 18:40:24 +02:00
BruebachL
712ca61a9e
[Themes] Encode home backgrounds as JPEG to slim the repo (#7346)
The home background PNGs shipped since #7280 total ~67 MB and bloat
every clone and pull for other devs. Encode them as JPEG at quality 95
(matching resolution, visually lossless at 40-48 dB PSNR since the art
is noise-heavy renders PNG cannot compress) for ~79% size reduction.

The scheme-variant and theme asset resolution already probe .jpg, so
only the qrc entries needed updating.

Refs #7327

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-24 18:40:04 +02:00
BruebachL
eae9591a17
[Server/Client] Unify report categories (#7345)
* [Server/Client] Unify report categories into one shared source of truth

* Lont.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-24 18:37:54 +02:00
BruebachL
a1d8ce6165
[Accessibility] Bind card size sliders to Ctrl + Scroll (#7338)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Ctrl+scroll over a card display now resizes the cards everywhere a card
size slider is shown, matching the standard way of resizing content.

CardSizeWidget learns to forward Ctrl+wheel events onto its slider via an
event filter that is installed on the display container and, when present,
on the scroll area's content widget so the resize intercepts the wheel
event before the view scrolls. The existing slider valueChanged wiring
then rescales the displayed cards.

Applied to the visual deck editor (per card group, covering flat and
overlapped layouts), visual database display, printing selector, sample
hand, visual and public deck storage, Archidekt previews and EDHRec card
displays.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-23 21:03:34 +02:00
BruebachL
71ef36374d
[DeckEditor] Show card counts in Visual Deck Editor banners (#7339)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-23 19:19:30 +02:00
BruebachL
7a41dfe175
[PictureLoader] Fix custom-folder pictures in subdirectories not loading (#7340)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
CardPictureLoaderLocal compared the full file name (including extension)
of the custom-folder candidate against the extension-stripped base name of
each directory entry, so a CUSTOM subfolder image like
pics/CUSTOM/poker/1 of Hearts.png never matched and the card fell through
to the network. Compare the complete base names instead.

Add matcher tests covering CUSTOM subfolder resolution.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 21:48:34 +02:00
BruebachL
e97ef5a617
[Style] Do not offer broken windows11 (#7341)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 21:45:11 +02:00
BruebachL
fe83966087
[Theme] Revert default light palette and only include AppColors (#7342)
* [Theme] Revert default light palette and only include AppColors

* [Style] Also remove dark mode palette

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 21:45:05 +02:00
BruebachL
e01912d3c8
[Tests] Add manual picture loader benchmark against the real card hosts (#7288)
* tests: add manual picture loader benchmark against the real card hosts

(cherry picked from commit 588d71d3b9a8278ab98b847802908f322c1035b7)

* tests: address review on picture loader benchmark

- Sandbox via unique app/org names plus Linux-only XDG redirection, derive
  the warm-cache probe and data path from SettingsCache, and bail out when the
  temporary sandbox cannot be created.
- Run each pass with a fresh worker and shut workers down before reading the
  429 counters, so the redirect cache is persisted and no worker thread can
  outlive the stack-local counters or the installed message handler.
- Make s_activeCounters atomic and always forward log output when the previous
  handler is the built-in (nullptr) one.
- Validate --timeout-min, scale the cached-pass budget with --count, honour the
  first --url as the stress template, and add the missing trailing newlines.
- Zero-initialise SettingsCache members so the benchmark mock cannot
  dereference an indeterminate pointer.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 18:30:04 +02:00
BruebachL
9f66a098ac
[PictureLoader] Fix worker thread shutdown and cross-thread cache clearing (#7292)
* [PictureLoader] Fix worker thread shutdown and cross-thread cache clearing

clearNetworkCache() ran directly on the UI thread while the worker thread
owned the disk cache and redirect cache, racing cache reads/writes. Make it
a worker-thread slot invoked via a blocking queued call when the thread is
running, so the 'Cached card pictures have been reset.' message is truthful.

The worker thread was also never quit()/wait()ed: both destructors only
deleteLater'd their objects, so Qt warned 'QThread: Destroyed while thread
is still running' and leaked a running loop at exit. Wire the worker's
finished() signal to its own deleteLater() (canonical worker-object
pattern), add shutdownThread() to stop the loop, and let CardPictureLoader
destroy the QThread only after wait() has returned.

* [PictureLoader] Guard cache teardown and stop blocking the UI thread

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 18:30:03 +02:00
BruebachL
14acf3bf64
[PictureLoader] Add user-configurable per-host request caps (#7287)
* [PictureLoader] Add user-configurable per-host request caps

Picture downloads were throttled to a uniform 10 requests/second per host
with no way to tune a specific server. A rate-limited API host (Scryfall
caps at 10 req/s) can trip 429s during bursts, and CDN hosts with no rate
limit were throttled needlessly.

Introduce developer-owned per-host caps that users can only ever lower,
never raise, exposed in the download settings page:
- DownloadSettings::DEVELOPER_HOST_CAPS sets the ceiling per host
  (api.scryfall.com 9, cards.scryfall.io unlimited, others 10).
- A new hostRequestLimits setting stores user overrides in downloads.ini;
  clampHostRequestLimit() bounds them to [1, devCap] so a user can reduce
  api.scryfall.com to 5 but never raise it above 9.
- The picture worker seeds, halves on 429, and recovers its sustained
  per-host allowance against the effective ceiling instead of the global
  maximum, and skips per-host accounting entirely for unlocked hosts
  (cards.scryfall.io) while global pacing and 429 backoff still apply.
- The deck editor settings page gains one spinbox per known host, each
  clamped to its developer cap.

* [PictureLoader] Let unlocked hosts skip dispatch pacing; adjust limits per URL

Two refinements to the per-host request caps:

- Unlocked hosts (UNLIMITED_HOST_QUOTA, e.g. cards.scryfall.io) no longer
  wait on the 100ms dispatch pacing or consume the global per-second quota.
  dispatchQueuedRequest fires their queued requests back-to-back, bounded
  only by their 429 backoff window and Qt's per-host connection pool, so
  an unthrottled CDN is not artificially slowed.
- The deck editor download settings page replaces the static grid of one
  spinbox per known host with an "Adjust Rate Limit" toolbar action on the
  URL list. It picks the host out of the selected URL and clamps the entry
  against the developer cap table (including for user-added URLs).

Also fixes a review finding: resetRequestQuota could write the
UNLIMITED_HOST_QUOTA sentinel (-1) into the sustained per-host quota when a
host became unlocked mid-run, permanently poisoning its allowance. Stale
entries for unlocked hosts are now dropped, and the per-second seed is
clamped against the effective ceiling so a lowered limit applies immediately.

* [PictureLoader] Cap unlocked host bursts and adapt them to 429s

* [PictureLoader] Store per-host limits readably and show them per URL

* [PictureLoader] Make dispatch and rate-limit bookkeeping key on the real host

Addresses ZeldaZach's round-4 review nits:

- Dispatch now resolves the cached-redirect chain before the in-flight gate,
  so a redirect learned after a URL was queued can no longer bypass the
  MAX_IN_FLIGHT_PER_HOST cap and drain the whole queue onto the redirect
  target, which may carry its own developer cap. processSingleRequest does
  the same so the allowance math keys on the host that is actually hit.
- The per-host in-flight slot is released when the reply is destroyed (with
  the worker as the connection context) rather than on a 'finished'
  connection bound to the work object, so an aborted reply or a work object
  deleted while a reply is pending can never permanently shrink the fast
  path's concurrency.
- storeSettings only prunes limits for hosts with neither a URL nor a
  developer cap, so throttles on redirect targets (api.scryfall.com ->
  cards.scryfall.io) survive URL removal.
- Unlocked hosts are offered 0..UNLOCKED_HOST_LIMIT_MAX (50) in the rate
  limit dialog, matching clampHostRequestLimit() and the documented
  hand-editable range, so values written into downloads.ini are no longer
  silently rewritten on the next edit.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 18:30:03 +02:00
BruebachL
3d5eb84d81
[PictureLoader] Seed per-host allowances on demand and skip hosts in 429 backoff (#7286)
* [PictureLoader] Seed per-host allowances on demand and skip hosts in 429 backoff

The quota reset re-filled every host's remaining allowance to a full
MAX_REQUESTS_PER_SEC as soon as the queue had a request for it, so a server
that was just rate limited could be hammered again at full speed immediately
after (or even during) recovery.

Only seed a host's allowance the first time it is dispatched in the current
second, seeded from its reduced sustained quota, and skip hosts still inside
their 429 backoff window entirely, handing the entry back to its worker so it
can wait the backoff out or fall through to another source instead of parking
in the queue with no reply pending. Deferrals wait on the host that is
actually blocking the request (cached-redirect targets and the reply host of
a 429) rather than the current card URL's host.

Rebased onto network-requests/request-pacing, which absorbed the earlier
pacing and dispatch-guard commits, and reuses its updateTimerState idle-429
recovery plumbing.

* [PictureLoader] Hand backed-off entries back across the whole dispatch tick

Review fixes on the 429-backoff skip:

- Hand a backed-off queue entry back to its worker via
  scheduleDeferredRetry(host) instead of startNextPicDownload(), which was
  looping on the pre-redirect host every 100ms tick whenever the queued URL
  was a cached-redirect target whose redirect host was the one in backoff.
- Keep scanning the queue after a hand-back (--i; continue) so a backed-off
  host at the head no longer consumes the entire dispatch tick, stalling
  every healthy host further down.
- Emit imageRequestSucceeded(url) before makeRequest()'s cached-redirect
  backoff return so the status bar reclaims the deferred URL's widget instead
  of inflating the progress bar forever.
- Only spend a per-second allowance when makeRequest() actually issues a
  request: it returns nullptr when the redirect target is backed off and the
  work is handed back, so the slot would otherwise be wasted on a no-op.
- Gate the dispatch-time backoff skip on requestTouchesNetwork() so entries
  served straight from the disk cache (e.g. with downloads disabled) are not
  bounced into a 30-60s deferred retry for a host that 429'd earlier.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 18:30:03 +02:00
BruebachL
334a743953
[PictureLoader] Pace requests and run the throttle timers on the worker thread (#7285)
* [PictureLoader] Pace requests and run the throttle timers on the worker thread

Previously the whole backed-up queue was drained in a burst as soon as a
request was enqueued, sending up to 10 requests back-to-back and then
immediately re-filling the quota one second later. That hard-bursts a
rate-limited API like Scryfall's (10 requests/second) into a 30 second
lockout.

Introduce a pacing timer that dispatches a single queue entry every
100 ms, so the per-second allowance is used smoothly instead of in spikes,
and keep the quota timer at 1 second. Also fix both timers' thread
affinity: they are QTimer value members and so are not QObject children,
meaning moveToThread() on the worker left them on the main thread while
the slot code started them from the picture thread, which was a no-op that
also warned. They are moved to the worker thread explicitly and started
lazily from there.

* [PictureLoader] Guard dispatch timer restarts and drop dead request quota

* [PictureLoader] Reconcile quota-timer lifecycle with idle 429 recovery

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 18:30:02 +02:00
BruebachL
7a2492ac67
[Chat] Render room chat history usernames as live user tags (#7269)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* Render room chat history usernames as live user tags (#1595)

Room chat history carries no user-level data, so history usernames were
rendered as fixed, zero-level tags: the moderation context menu was missing the
buddy/ignore and promote/demote entries and the stored name casing was never
corrected.

Resolve each history author against the online user list and, when found, build
the user tag with the real user level and name so the entry behaves exactly like
a live chat tag. Offline users keep the plain fallback.

- chat_view: look up history authors via getOnlineUser for the real level/name

* Fix offline history usernames getting a leading underscore

The offline fallback used "_" as the level placeholder, producing an
href of user://__NAME. The hover handler splits at the first underscore,
so interactions targeted a nonexistent "_NAME" user. Use level 0 so
offline history entries render as zero-level tags like before.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 09:13:55 +02:00
BruebachL
ef68a7bdcc
[Card] Add a setting for the language used in card search (#7314)
* [Card] Add a setting for the language used in card search

Localized card names and texts can now be searched too, controlled by a
'Language used in card search' toggle (English, selected card language, or
both) on the general settings page. Untranslated cards always keep matching
in English.

Removed the redundant local copy of the URL templates list in the localized
picture loader while here.

* [Card] Bind search language per FilterString instance

The peg parser rules are set up once per process, so the GenericQuery and
OracleQuery rule actions could not capture per-instance state. Instead of
storing the search language in a process-global that FilterString instance
methods mutate, hand it to the rule actions through a thread-local parse
context and copy it into the filter closures they produce. Card evaluation
in FilterString::check no longer reads any process-global state, and each
instance keeps the language it was built with; constructing one instance no
longer changes what unrelated instances (deck filter, drop-to-hand, zone
views) match against.

The card database display model stores the raw query and rebuilds the
FilterString when the search language changes, since the language is now
bound at parse time.

Add tests for the English/Selected/Both search modes, the English fallback
for untranslated cards, and per-instance language independence.

* [Card] Pass the card search language to deck and zone card searches

Wire the two remaining FilterString consumers to the configured card search
language so card-name matches respect it everywhere:

- DeckFilterString now takes the search language and mode, exposes them to its
  [[card name]] rule action via a thread-local parse context (same pattern as
  FilterString), and the engine's card database uses them for content search.
- ZoneViewZone reads the card language from CardsDisplaySettings when applying
  its search filter, and the reveal-zone widget re-applies the active search
  when the language setting changes.
- The deck-storage search re-runs its filter against the current card language
  setting, including live re-application when the setting changes.

Game-action targeting (DlgMoveTopCardsUntil) intentionally keeps evaluating
against English card names.

* [Card] Rename CardSearchLanguage to SearchLanguageMode

* [Card] Restore displaced namespace doc in card_localization.h

* [Filters] Pass CardSearchLanguage as a single struct

* [CardSearchModel] Match English and localized names in Both mode

Card names are stored in both English and localized forms, so search for
matches in both during the 'Both' search mode instead of checking only
the localized name.

* [CreateTokenDialog] Fetch cardsDisplay settings inside the apply lambda

Avoid capturing the raw settings pointer in the lambda: resolve the card
language and card search language from the settings cache at call time so
the values are always current when the search language is re-applied.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-21 08:50:48 +02:00
BruebachL
6823d54c1e
[DeckShare] Browse and open public decks with loading, error and accessibility states (#7245)
* [DeckShare] Browse and open public decks with loading, error and accessibility states

Add a public-decks tab that lists decks published by other users using the
server's deck visibility feature, previewing each deck's banner card, color
identity, tags and upload time without downloading the deck list until the
user opens it.

- Add a public-decks tab with a shared-settings widget and a remote model
  that fetches the target user's decks and refreshes both automatically and
  on user request, with a loading indicator and a server-error message
  instead of a blank tab when the fetch fails or the connection drops
- Render each deck as a focusable preview tile whose banner, color identity,
  tags and upload time follow the existing Preview settings, with the deck
  name announced as the tile's accessible name and Space/Enter opening the
  deck, mirroring the shared-deck preview tile
- Show a message box when opening a public deck fails or arrives corrupted
- Publish and unpublish decks from the server storage toolbar and context
  menu, toggling the deck's own visibility bit (what the server persists)
  rather than the inherited effective state, and batch the visibility
  refresh until the last in-flight change is acknowledged
- Add the Show Upload Time setting so the tile's upload stamp can be hidden
  like the other preview details
- Update the retranslateUi wiring for the new public-decks tab and rename
  the share action tooltip from "Deck share" to "Share link"

* [DeckShare] Adapt deck upload to the server-derived banner and tag protocol

The server now derives the banner card and tags from the uploaded deck
list itself, so Command_DeckUpload only carries the client-computed color
identity. Drop the reserved banner/tag setters from the editor and storage
uploads, send the color identity on remote saves, and read tags from the
now-repeated ServerInfo_DeckStorage_TreeItem field.

* [DeckStorage] Refresh the visibility column with a guarded timer instead of a latch counter

A dropped visibility reply used to leave the pendingVisibilityChanges
counter permanently positive, so the Public/Private column never refreshed
again and nothing reset it on disconnect. A restartable single-shot timer
with a boolean guard re-reads the tree whenever publishes quiet down and is
stopped on disconnect, so a lost reply costs one stale refresh instead of
killing the column for the session.

* [DeckStorage] Summarize batch publish failures when the batch drains

Each rejected node stacked its own modal dialog, so publishing a ten-deck
selection against a rejecting server made the user dismiss ten dialogs one
at a time. Failures are now collected while the batch is in flight and shown
as a single summary when the visibility refresh timer fires; a reply that
lands outside an active batch still reports right away.

* [PublicDecks] Time out the loading state so a dropped reply cannot wedge the tab

loading only cleared in decksReceived, but the ping sweep can drop a pending
command without ever emitting finished, leaving the tab stuck on 'Loading
public decks...' and the refresh button permanently inert. A single-shot
timer started per refresh clears the latch and reports a timeout; the latch
also clears when the client disconnects.

* [PublicDecks] Escape remote-crafted text in tooltips and the tab title

Deck names and usernames come from other users' records and Qt renders
QLabel tooltips as AutoText, so a name like '<h1><table>...' parsed as
markup. Escape and bound the deck-name tooltip and escape the username
interpolated into the title label.

* [DeckStorage] Distinguish an inherited public state in the visibility column

The column reported the effective state while publishing toggles the node's
own bit, so a private deck inside a public folder already read 'Public' and
toggling appeared to do nothing (and toggling again silently unpublished
it). The cell now shows 'Public (inherited)' for that case and the tooltip
explains why.

* [PublicDecks] Run retranslateUi at construction and name the refresh button

retranslateUi was never called from the constructor, so the tooltips set
there were absent until a language change. Call it before the first refresh,
and give the icon-only refresh button an accessible name for screen readers.

* [PublicDecks] Keep the empty and status variants correct across language changes

retranslateUi unconditionally rewrote the empty label to the 'nothing
published' variant, stomping the 'no decks match your filters' choice
rebuildGrid had made, and a visible loading message stayed in the old
language. Let retranslateUi pick the same variant rebuildGrid does and
re-show the status so it retranslates.

* [VDS] Share one color-identity match rule between the two deck grids

The remote public decks model verbatim-copied updateColorMatches' switch,
down to the ExactMatch normalization and the fact that Includes/Excludes do
not normalize case. Extract colorIdentityMatches() next to the FilterMode
enum and call it from both so the subtle rule cannot drift.

* [DeckStorage] Drop the unused tree widget model accessor

The accessor handed the model out past the wrapper methods that exist to
keep it encapsulated, and nothing in the stack called it.

* [DeckShare] End the public-decks files with a trailing newline

keeps the final line's diff clean and stops clang-format CI from flagging
the files.

* [VDS] Reuse the shared quick settings widget for the public decks tab

PublicDecksQuickSettingsWidget was VisualDeckStorageQuickSettingsWidget
minus the folders, banner and tooltip controls, with identical wiring for
the shared keys and a version of the near-identical file to keep in sync by
hand. Fold the Show Upload Time checkbox into the shared widget, give it a
setPublicDecksMode() that hides the controls that do not apply, and delete
the duplicate.

* [PublicDecks] Drop stale deck-list replies after the loading timeout

A reply that lands after its own loading timeout (the reverse of the ping
sweep dropping the command) could stop the newer request's timeout timer
and repaint the grid with out-of-date data. Each refresh now captures a
monotonically increasing request id, and only the newest request's reply
updates the grid.

* [PublicDecks] Re-show a displayed failure message on language changes

The status label carries both the loading and the failure message, and
retranslateUi hid it whenever the model was not loading, so a language
change while a server-error or timeout message was on screen swapped it
for the (empty) grid. The tab now keeps the last failure text and
re-shows it when not loading, clearing it once a new refresh starts.

* [DeckStorage] Keep the visibility refresh armed until replies land

The single-shot drain was armed with the 500 ms delay at send time, so a
round trip slower than that drained before the server applied the change,
re-read the old state and never re-armed, leaving the column stale until
a manual refresh. The timer is now armed with the full network timeout at
send time (a lost reply still costs one stale refresh) and re-armed with
the short delay every time a reply lands.

* [DeckShare] Close public decks tabs when the client disconnects

TabSupervisor::stop() built tabsToDelete from the room and game tabs
only, so a public decks tab survived a disconnect, sitting with stale
contents and a refresh button that kept hitting the dead client. Its
values are now folded into the same cleanup.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-20 20:22:17 +02:00
BruebachL
8ca749c07d
[DeckShare] Open shared decks via links with a gated preview flow (#7244)
* [DeckShare] Open shared decks via links with a gated preview flow

- Serialized url-chain dispatcher in IntentUrlParser; queue-drained
  urlChainFinished(bool) drives the startup auto-connect fallback
- Open-shared-deck intent with sequential download state machine,
  15s per-item timeout, partial-success offer, livable Cancel via
  ApplicationModal dlg_login_prompt interactive fallback
- Preview dialog: download progress label, share vocab sweep,
  palette-highlight selection frame, Space/Enter keyboard toggle,
  NoFocus checkbox, double-click tile opens immediately
- Confirm-before-server-migration with one-shot restore to the
  previous server on failed/cancelled chains (statusChanged settle
  deferral), hostname-only identity comparisons
- Skip credential link when already connected; arrow-key navigation
  in FlowWidget; card glows use palette highlight
- Address code-review M1-M4 and UI/UX QA blockers 1-2

* [DeckShare] End the open-shared-deck files with a trailing newline

* [DeckShare] Forward a dependency's cancellation as the owner's own

* [DeckShare] Let intent chains opt into the link sign-in dialog

* [DeckShare] Track link-intent chains per-run so each can restore its own session

* [Settings] Match a server on the exact host and port when adding it

* [DeckShare] Confirm the share link's target server before opening a deck

* [DeckShare] Reformat the link sign-in intent constructor

* [DeckShare] Time the share-list round trip and backstop silently-destroyed intent chains

* [Client] Drain a single-instance payload before its handlers read the socket again

* [Client] Treat a busy single-instance primary as alive instead of stealing its socket

* [DeckShare] Keep arrow-key navigation between flow items inside a scroll area

* [Client] Skip the startup connection when a macOS URL launch owns the connection

* [Client] Redact share secrets from activation URL logs

* [Client] Make the link-connection gates port-aware and keyboard-safe

Second-pass review notes for the shared-deck link flow (Cockatrice#7244):

- FlowWidget arrow-key navigation is opt-in via addNavigableWidget, so
  combo/spin controls on the analytics flows keep their own arrow keys
- isConnectedTo and the open-deck/join-game preconditions compare the
  configured server port alongside the host, so a same-host/different-port
  link cannot resolve its share token or game id on the wrong instance
- the link sign-in dialog reuses an existing server entry's saved name
  instead of renaming it to the raw hostname
- skipStartupAutoConnect is cleared once the launch chain connects, so a
  later mid-session declined link cannot fire the startup fallback
- the plain-launch path of SingleInstanceManager no longer blocks on the
  primary's ACK
- link- and server-supplied text is html-escaped in the confirm prompts and
  shared-deck preview so markup cannot spoof the shown messages

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-20 20:22:17 +02:00
BruebachL
ba2900dcb9
[DeckShare] Create temporary share links for local and server decks (#7243)
* [DeckShare] Create temporary share links for local and server decks

* [DeckShare] Address review findings and harden the share flows

Gate every share entry point on login, de-duplicate the share-link and
color-identity logic behind DeckShareUtils and an injected querier, and
replace the silent tray/status-bar notices with always-visible dialogs.

- abstract_tab_deck_editor: explain that sharing requires a connection
  instead of silently doing nothing when logged out
- tab_deck_storage: disable the share action on disconnect, reject
  folder/deck mixes and the root folder with clear warnings, re-enable
  Create on every entry/response so a dropped connection cannot leave
  the button disabled
- tab_deck_storage_visual: same login gate for the context-menu entry,
  visible success/error dialogs, and a symmetric in-flight guard
- getDeckColorIdentity now takes a CardDatabaseQuerier, dropping the
  CardDatabaseManager singleton access and enabling unit tests

* [DeckShare] Fix share-link expiry build on the minimum-supported Qt

QTimeZone::UTC (the Initialization enum) only exists since Qt 6.7, so
Debian 12 and Ubuntu 24.04 (Qt 6.4) fail to compile the share-link expiry
handling in the share dialog and the two deck-storage tabs. Mirror the
existing games_model guard and fall back to Qt::UTC on older Qt.

* [DeckShare] Use the stable server client for the visual deck storage tab

* [DeckShare] Extract the share-creation response handling into DeckShareUtils

* [DeckShare] Drop includes left unused by the share-response extraction

* [DeckShare] Format share expiry with the locale-aware short format

* [DeckShare] Build share links with QUrl and QUrlQuery for percent-encoding

* [DeckShare] Replace the duplicate computeColorIdentity with the shared getDeckColorIdentity

* [DeckShare] Recover the share controls when the server never answers

* [DeckShare] Provide the full share hint in each plural form

* [DeckShare] Join the selected-count label with a non-translatable separator

* [DeckShare] Retranslate the share button tooltip with the storage widget

* [DeckShare] Forward retranslateUi to the visual deck storage widget

* [DeckShare] Let the share bar owners supply the hint text

* [DeckShare] End the share-related headers and sources with a trailing newline

* [DeckShare] Keep the settings include in the project include block

* [DeckShare] Include the network settings header used by the share timeout

* [DeckShare] Resolve the share theme icon through themePixmap

QPixmap("theme:icons/share") has no file extension, so ThemeManager::assetPath()
is bypassed and the pixmap is always null. Use themePixmap(QStringLiteral("icons/share"))
like every other toolbar action, so the .svg (and dark/light variants) resolves.

* [DeckShare] Keep the share selection consistent with the visible decks

Filtered-out previews are hidden but kept alive, so selectedFilePaths() counted
them in the share and the selection highlight. Only decks the user can see are
now shared, and a deck that stops matching the filters is deselectd as the deck
pass runs, keeping the %n count and the highlight in sync with the screen.

* [DeckShare] Abandon an in-flight tree share on cancel

Leaving share mode never stopped the timeout timer, and a late response still
ran shareFromTreeFinished, copying the link and announcing success for a share
the user backed out of. Stopping the timer and tracking the outstanding request
by sequence number means a stale reply (or a timed-out one) after cancel is
ignored, and cancelling + re-entering share mode can no longer confuse the two
requests.

* [DeckShare] Abandon an in-flight tile share on cancel

exitShareMode() left shareTimeoutTimer running and did not abandon the pending
Command_DeckShareCreate, so a timer pop or a late success still reported the
share after the user cancelled. Stop the timer and ignore stale responses via a
sequence number, mirroring the tree tab.

* [DeckShare] Wire the status-changed handler after shareBar exists

handleConnectionChanged() dereferences shareBar->isVisible(), but the connection
was set up before shareBar was constructed and shareBar had no in-class
initializer. On any status change delivered before construction the slot read an
indeterminate pointer. Seed the connection (and the initial share availability)
after shareBar exists and give shareBar a = nullptr initializer.

* [DeckShare] Explain why a blank deck cannot be shared

A blank deck exited the share flow silently. The menu only disables the entry
via setSaveStatus(), a different predicate, so the path is reachable (e.g. add a
card and remove it again). Mirror the not-logged-in branch with a short
information dialog.

* [DeckShare] Restore the banner-text doc comment

Re-add the doc block above refreshBannerCardText() that was removed as part of
the share-selection work; it documents the coupling to refreshBannerCardToolTip.

* [DeckShare] Resolve the stable server client in the deck editor gate

actShareDeck went through tabSupervisor->getClient(), which hands back a
LocalClient while an offline game is running. LocalClient never sets its status,
so a logged-in user could not share from the deck editor during a local game,
and got a misleading "You must be connected" message. Expose the supervisor's
stable remote client and use it for the gate and the dialog, matching the other
share tabs.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-20 20:22:16 +02:00
BruebachL
a289d61765
[VDS] Decouple tag filter and fix reordered-chips crash (#7242)
* [VDS] Decouple tag filter and fix reordered-chips crash

* [VDS] Address review: dead code, chip reparenting, filter signal and sort fast-path

* [VDS] Address second round of review nits

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-20 20:22:16 +02:00
BruebachL
a5e94d8a4f
[Build] Keep Windows installs free of build-tree artifacts (#7316)
* [Build] Keep Windows installs free of build-tree artifacts

Several Windows packaging gaps could leak Visual Studio CMake build
output into the installed application or the NSIS installer:

- The per-app DLL sweep used
  ${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}, which is
  empty on multi-config generators, collapsing the recursive
  DIRECTORY install into the whole build tree (containing *.dir,
  *_autogen, .qt, .qsb, x64, ...). Point it at the real per-config
  output with $<TARGET_FILE_DIR:...> and exclude build artifacts.
- install(FILES ${OPENSSL_INCLUDE_DIRS} ...) tried to install OpenSSL
  include directories as files. CMake refuses this
  ("install FILES given directory"); it only slipped through CI
  because the vcpkg OpenSSL config leaves the variable empty. Remove it;
  fixup_bundle already ships the OpenSSL runtime DLLs.
- The NSIS uninstaller only deleted *.exe/*.dll and a few known files,
  so build-tree leftovers survived an uninstall/reinstall cycle. Wipe
  the whole directory tree instead.
- Add a Windows CI gate that lists the packaged installer with 7-Zip
  and fails the build if any build-tree artifact path is found.

* Update .ci/compile.sh

Co-authored-by: tooomm <tooomm@users.noreply.github.com>

* [Build] Rework Windows installer artifact exclusions per review

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: tooomm <tooomm@users.noreply.github.com>
2026-09-20 20:21:52 +02:00
BruebachL
db2e159dca
[Game] Allow judges to enter any game regardless of restrictions (#7315)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-19 10:39:52 +02:00
BruebachL
9acb9739b2
[Client] Fix spurious server room join error (#7259)
* [Client] Fix spurious server room join error

The server replies RespContextError when a join command is received for a
room that connection is already registered in. The client was sending such
duplicate joins in benign situations - double-clicking to join a room, or
clicking a room the selector was already auto-joining - and answered them
with a modal telling users to restart the client.

Joins for the same room are now deduplicated while one is in flight, and a
remaining RespContextError is healed by leaving and rejoining the room so
the tab appears without a client restart. Error dialogs are only shown for
user-initiated joins, so failed auto-joins no longer spam critical popups.

* [Client] Bound stale-membership room join heal to one attempt

The RespContextError heal (leave + rejoin) previously recurred
unconditionally, so a server that kept returning RespContextError for a
reason other than stale membership would loop forever. Track room ids
that already received a heal and surface the error dialog after one
attempt instead of retrying indefinitely.

* [Client] Scope room-join heal guard to one join attempt

* [Client] Hoist room-join heal guard lookup out of response switch

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-19 09:56:09 +02:00
BruebachL
59dd052143
[DeckEditor] Restore auto-scroll when adding cards (#7317)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-19 09:55:30 +02:00
RickyRister
51365960fe
[Client] Create custom pawn for dev role (#7295)
* [Client] Create new pawn for dev role

* lighten color
2026-09-18 10:58:10 -07:00
BruebachL
7b39cf98d5
[Themes] Add identity default palettes for the image themes (#7280)
* [Themes] Add identity default palettes for the image themes

The Fabric, Leather, Plasma and VelvetMarble themes shipped only zone art and no palette, so their chrome fell back to the bare OS palette. Each theme now ships light and dark curated defaults written in the palette editor's own conventions, with [AppColors] so the home-tab buttons keep the theme's identity over static backgrounds.

- Add palette-default-light.toml / palette-default-dark.toml for the four themes; scheme resolution follows the OS since the themes declare no color scheme.
- Tint the window/base/button chrome and bevels towards each theme's identity: navy cloth (Fabric), black-brown with brass accents (Leather), electric violet with cyan sparks (Plasma), charcoal velvet with slate marble (VelvetMarble).
- Fill [AppColors] AccentStrong / AccentSoft per theme so the home-tab button gradient matches instead of falling back to the default greens.
- Derive Disabled and Inactive groups with the same conventions as PaletteGenerator::fromAccent.

* [Themes] Add home backgrounds for the image themes

* [Themes] Ship Fusion style + scheme backgrounds for the image themes

Address PR review:
- Add theme.cfg ([Style] Name = Fusion, ColorScheme = System) to Fabric,
  Leather, Plasma and VelvetMarble so their curated palettes actually apply.
  Without it the native style paints button chrome from the OS (windowsvista
  on Windows has no dark mode), making the dark palettes' light ButtonText
  unreadable on light buttons.
- Leverage the scheme-variant asset resolution: register home-dark.png and
  home-light.png in resources and add them to the qrc so the built-in home
  background also flips with the palette instead of staying static.
- Fix Leather [Palette.Inactive] Accent, which copied the Active Highlight
  color instead of the theme accent (dark #4a5f8f -> #c9995a, light
  #34508c -> #a5712f) in both palette files.

* [Themes] Align light plasma home background with the dark variant

Recolored the Plasma light home background to mirror the dark one:
brighter violet/cyan blooms, vivid azure spark arcs, and higher accent
saturation against the same pale-lavender key. Brightness is unchanged
so it still reads as a light scheme.

* [Home] Add option to disable the home tab background dim

Adds a 'Dim the home tab background' checkbox to the Home tab settings
page (Appearance). When unchecked, HomeWidget skips the translucent
black overlay it paints over the whole background. Default is on,
preserving current behavior; the home tab repaints live on change.

* Update leather backgrounds

* Update velvet marble backgrounds

* Update light plasma background

* Update light fabric background

* WIP [UI] Theme-aware onboarding banner with frosted light mode

Banner colours now derive from palette tokens at ~60fps (tick-driven,
equality-guarded setters) so scheme switches and live accent-picker
previews apply instantly:

- dark stages: byte-for-byte the original treatment (near-black stage
  from window hue, Highlight accent, white centre halo, vignette 0.62)
- light stages: pastel accent-hue wash instead of a neutral grey copy,
  brightness-lifted accent for additive glow legibility, deep-Highlight
  halo (uGlowColor) instead of white blowout, gentler vignette
  (uVignetteMin 0.88) so corners don't go muddy
- black logo silhouette variant selected on light stages
- theme picker preseeded with brand green (brand_colors.h single source)

WIP notes for next session:
- real-pixel wizard screenshot check still pending (headless capture
  exists: Xvfb :77 + isolated XDG_DATA_HOME; shader vs fallback pixel
  analysis not finished)
- user plans separately: promote Fusion to default theme, Default -> system

* [Themes] Align Fusion accent tokens with the SVG brand gradient

Align AccentStrong (#139740) and AccentSoft (#c9fd62) and the linked
Link/Accent roles with cockatrice.svg's linearGradient4265-7-8 stops so the
identity gradient used by the logo emulation matches the static art the icon
shipped.

* [Onboarding] Draw the banner logo as a static gradient plate

Replace the black/white logo tint switch with a ShaderEffect plate that
repaints the SVG's brand gradient (light AccentSoft -> dark AccentStrong
along the baked-in userSpaceOnUse axis) clipped to the full-color logo's
alpha silhouette, with the white highlight path overlaid on top — matching
the home widget's QPainter composite. The plate is static: no glow or
breathing. Brand colors flow from BannerShaderConfig's new brandStrong/
brandSoft pair instead of the removed logoDark flag, and the background
motifs get a touch more accent so the mark keeps its coloured surround.

* [Home] Draw the featured logo as a theme gradient composite

Repaint cockatrice.svg in Qt instead of showing the baked-in static art:
fill the full-color logo's alpha silhouette with the same brand gradient the
banner plate uses (light AccentSoft grading to dark AccentStrong along the
SVG's userSpaceOnUse axis), then overlay the white highlight path. Renders
an explicit QPixmap so the mark stays crisp at the 200px display size, and
re-seeds it on theme/palette/appearance changes so it never goes stale.

* [Resources] Drop the unused black logo asset

No consumer remains after the banner's logoDark toggle was replaced by the
static gradient plate (unit-tested in d7529e0c6a), so remove
cockatrice-logo-black.svg and its qrc entry.

* [Onboarding] Seed the theme picker from the theme's identity accent

Replace the hardcoded brand-green preseed with the shipped theme's own
AccentStrong (Plasma seeds violet, Fusion green), resolved from the default
palette so auto/user-generated palettes can't mask it, and re-seed whenever
the theme changes so the swatch never goes stale. Also consult the shipped
palette in maybeAutoGeneratePalette so scheme flips don't regenerate a fresh
palette over curated theme colors.

* Regenerate zone assets for Fabric, Leather, VelvetMarble themes

Align zone textures with each theme's palette identity:
- Fabric: linen weave in navy/steel tones (was generic green/blue/red/gray)
- Leather: grain texture in warm brown/amber tones
- VelvetMarble: smooth charcoal marble veining (replaces .jpg with .png)

All zones now have dark + light scheme variants for OS color scheme
adaptation. Plasma zones deferred to separate iteration.

* Regenerate Plasma zone assets: horizontal-hand layout, seamless tiling

Bake each zone's phase from its true world position in the horizontal-hand
layout (player 0, stack 172, table 280, hand 172+406) so the diagonal sheen
continues continuously across zone boundaries. All zones share one
mathematically tileable (1,1) diagonal at frequency 3; playerzone gets a
distinct blue-violet identity and the light scheme gets extra contrast.

* Regenerate VelvetMarble zones: classic Perlin marble, seamless tiling

* Regenerate VelvetMarble zones: domain-warped fractal veins, stone grain

* Promote Fusion to default theme, rename Default to System

Fresh installs and new profiles now default to the Fusion (dark) theme
instead of the platform-native theme. The old "Default" theme is
renamed "System" to better describe its purpose — using the OS-native
Qt style (windowsvista, macOS, etc.).

Existing users who had "Default" selected are automatically migrated
to "System" so they keep their platform-native styling. Users with
an empty or invalid theme name now fall back to Fusion.

* Move checkbox.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-18 15:16:07 +02:00
BruebachL
6c1d1c7b58
[Client] Add [AppColors] application palette roles (#7279)
* [Client] Add [AppColors] application palette roles

The home-tab buttons' gradient over the static theme background was hardcoded, and accent-derived fallbacks could not be themed or edited: QPalette's role set is closed, so any application-specific color has to live in Cockatrice's own palette layer.

- Add an AppColor::Role enum (AccentStrong / AccentSoft) stored on PaletteConfig and round-tripped from palette-<scheme>.toml under a new [AppColors] section.
- Cache the applied app colors in ThemeManager and expose appColor(Role) with a palette-accent-derived fallback; emit paletteChanged() from applyStyleAndPalette so previews, scheme switches and OS dark mode repaint palette-driven widgets.
- Fill both app roles in PaletteGenerator::fromAccent and surface them as a dedicated section in the palette editor.
- Drive the home-tab buttons from appColor() whenever the background source is the theme (any theme, not just built-ins).
- Ship AccentStrong / AccentSoft values in the Fusion and Default default palettes so the static home-tab buttons keep their classic greens.

# Conflicts:
#	cockatrice/src/interface/widgets/general/home_widget.cpp

* [AppColors] Address review comments

- PaletteEditorDialog::onSave(): compare whole PaletteConfig (colors and
  appColors) so a change to only AccentStrong/AccentSoft writes the file;
  add PaletteConfig::operator==.
- appColor(): derive both roles from QPalette::Highlight unconditionally.
  The Fusion palettes pin Accent to near-Window values, and QPalette::Accent
  only exists on Qt 6.6+, so keying on it made identical themes render very
  differently across Qt versions.
- themeChangedSlot(): merge the theme default's [AppColors] into a custom
  palette that predates the section instead of all-or-nothing per file;
  hasPalette() now counts an appColors-only file as a palette.
- Add Default/palette-default-light.toml so the Default theme's Light scheme
  keeps the classic greens instead of falling back to the OS accent.
- home_widget: restore the isBuiltInTheme() half of the Automatic condition;
  non-built-in themes extract button colors from their own background art.
- palette_grid_widget: use appEnum.value(i) for the role cast (3 sites),
  append appHeader to headerLabels, fix the 'Lighted' typo.

* [Themes] Route theme writes to the user themes directory

setColorScheme()/setStyleName() and the palette editor wrote directly to
the resolved theme directory, which for built-in themes is the read-only
system (install) location. Changes therefore landed in the install dir and
were lost on upgrade.

Add ThemeManager::writableThemeDir(), which always resolves to the user
themes directory, and route all theme writes through it. The palette editor
reuses the same helper, dropping its private writability probe.

* [Home] Replace 'Automatic' button color with explicit theme colors default

The Automatic option gated on isBuiltInTheme(): built-in themes used the
theme's accent colors, while non-built-in themes extracted colors from
their own background art. That made the result depend on the theme's
origin rather than what the user actually sees.

Remove Automatic and expose two explicit choices: 'From theme colors'
(always the theme's identity accents, now the default) and 'Extract from
background' (always sample the painted background). Drop the now-unused
isBuiltInTheme() helper.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-18 15:16:06 +02:00
BruebachL
456db56058
[PrintingSelector] Add Image Overrides submenu with hover preview (#7312)
* [PrintingSelector] Add Image Overrides submenu with hover preview

* [PrintingSelector] Address review comments

- Move QAction/QMenu forward declarations after the includes
- Use the renamed installPrintingOverride API and deleteAllLocalOverrides statically
- Drop the flavorName usage; Cockatrice does not use that field anywhere yet
- Extract the Load Custom Image handler into loadCustomImage()
- Make the preview size/offset constexpr and drop the redundant pixmap copy
- Extract the preview placement into a previewPositionNear() QPoint helper

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-18 15:03:09 +02:00
BruebachL
cca4af0ec7
[PictureLoader] Add local override storage and resolution with matcher tests (#7311)
* [PictureLoader] Add local override storage and resolution with matcher tests

* [PictureLoader] Address review comments

- Make deleteAllLocalOverrides static; it does not touch instance state
- Drop the now-unused hasCustomArt dead code
- Rename the override install methods to installPrintingOverride /
  installPrintingOverrideOnLoad

* [Tests] Give loader matcher tests a writable HOME in CI

Under GitHub's docker runner the process uid has no passwd entry, so HOME
resolves to '/' and the test-mode qttest data dir cannot be created.
SettingsCache's QSettings then drops every write, getPicsPath() comes back
empty, and the loader searches a blank path. Point HOME at a QTemporaryDir
for the duration of the run.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-18 15:03:08 +02:00
BruebachL
1c93309952
[Client] Show localized card names, texts and pictures (#7294)
* [Client] Show localized card names, texts and pictures

Localization wiring now runs end to end: the oracle importer collects
foreignData for the configured language and the client renders it.

- [Oracle] Import localized names and rules texts for the selected cardLang
  - single-face cards store their foreignData name and full text
  - multi-face (split/adventure/aftermath/prepare) cards collect the joined
    name once and join each face's translated text with the same separator
    as the English merge; an incomplete translation falls back to English;
    the joined text follows the same highest-priority-set policy as the
    single-face path and is only collected when localization is enabled
  - the wizard switching languages re-imports the card database

- [Client] Display localized card info throughout the client
  - card info text/picture widgets and the game board re-render on language
    change
  - pictures resolve cardLang art through Scryfall's named endpoint using the
    localized name, falling back to id-based art when no match exists
  - deck editor keeps canonical English names as card identity (EditRole)
    while showing localized names (DisplayRole), so decks and wire names
    stay stable

- [Card] Add CardLocalization-backed name/text lookup and cards.xml v4
  localization elements with a bounded-size translation cache

- [Tests] Cover oracle foreignData import (incl. multi-face joins, priority
  and fallback paths), XML v4 localization parsing, deck model localized
  display and the language-aware settings default

Existing installations need to re-run Oracle to see translations: localized
data only lands in cards.xml when the Oracle app is started with the
preferred language selected — launch the separate "Oracle" program that
ships with Cockatrice, pick the language in the wizard and let it re-import
the card database.

The client's database cache (cards.xml.cache) is invalidated by the cache
format bump and the source-hash checks, but a cache written before the
re-import can still hold English-only entries (the hash uses file size and
mtime, so a same-size/same-timestamp rewrite may be served as-is); delete
cards.xml.cache and relaunch if no localized names/texts show up after
re-importing.

* [Card] Pass localized card names and texts into CardInfo construction

Address review: instead of constructing the card and then calling
setLocalizedName/setLocalizedText (which emit a cardInfoChanged signal per
language), both constructors, both newInstance overloads and their callers
(cards.xml v4 parser and the binary cache reader) now pass the localized maps
as constructor arguments.

* [Client] Rename LocalizedCard:: helpers namespace to CardLocalization

The namespace now matches its header file name, as the review pointed out;
LocalizedCard reads more like a class or struct. Callers (card info text
widget, board card name rendering) are updated to match.

* [Client] Drop unused info member from the card info text widget

The CardInfoPtr member was only ever initialized to nullptr and never read;
remove it together with its initializer.

* [PictureLoader] Add the localized picture URL explicitly, not implicitly

Address review: silently prepending the Scryfall named-picture URL to the
download list whenever a non-English card language was active was surprising,
consumed quota per card when it failed, and could grab the wrong (canon) art on
name collisions, with no way to turn it off.

The insert is now opt-in and user-controlled: changing the card language adds
the template to the top of the download URLs once (persisted, documented in the
re-import prompt, and editable/removable in the deck editor settings), while the
picture loader no longer injects it at request time.

* [Card] Show card languages in the same native (English) format as the UI

Address review: the card text & images language dropdown listed bare native
names, some in inconsistent lowercase (e.g. "čeština", "español de España"),
which makes the languages easy to mix up for users that do not read the script
(e.g. 日本語 vs 한국어). It now mirrors the UI language dropdown and always pairs
the native name with its English name (e.g. "Deutsch (German)",
"日本語 (Japanese)"), using the same fixed casing.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-18 12:03:07 +02:00
BruebachL
fd82b140a8
[PictureLoader] Serve cached pictures from the disk cache instead of re-fetching them (#7284)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
With picture downloads enabled, requests were issued with AlwaysNetwork
cache control, which per Qt never consults the disk cache. A picture that
had already been downloaded was therefore fetched from the network again
on every session start, with the queue bypass letting those re-fetches
skip the rate limit entirely.

Treat the network cache as the intent of the 'Network Cache' storage
method suggests: if the URL is already cached, serve it with AlwaysCache
(no network, no quota); only a genuine miss goes to the network, and only
when downloads are enabled. Cache hits skip the queue for free since they
never consume the per-second request allowance.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-13 03:36:29 +02:00
tooomm
5d025ca0bd
Use capitalized app names (#7255)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* use capitalized app name

* update urls

* app description

* Update main.cpp
2026-09-12 17:30:46 +02:00
BruebachL
a85203e457
Migrate theme asset loads to scheme-variant resolution (#7209-2) (#7276)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Replace remaining QPixmap("theme:...") call sites with themePixmap() so icons, replay controls, card backs, and other images resolve to -dark/-light variants under theme schemes. Stem-exact 1:1 migration; behavior unchanged for non-variant themes.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-11 17:59:57 +02:00
BruebachL
e9bf1e6e46
[Theme] Add scheme-variant asset resolution (#7275)
* Add scheme-variant theme asset resolution (#7209-1)

ThemeManager::assetPath() and schemeVariantPath() resolve a theme asset to its scheme-variant file (prefix-light/dark.png) with fallback to the plain asset, and themePixmap()/loadBrush()/loadExtraBrush() use them. CSS files load style-dark.css or style-light.css when present. Home widget re-resolves its background on theme change.

Link pixel_map_generator.cpp into the oracle target, which needs Qt6::Xml for QDomDocument.

* Fix clang-format wrap of theme format probe lists

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-11 17:59:57 +02:00
BruebachL
202a5ac958
[Client/Server/Protocol] Surface live metrics in the Developer tab (#7212)
* [Server] Instrument command processing, game starts, and event loops

Add a lock-free MetricsRegistry that accumulates per-command processing
times in preallocated histogram slots (one per protobuf command type,
bucketed at 1/5/10/25/50/100/250/500/1000/2500/5000 ms +Inf). The
hot-path observeCommand() uses only relaxed atomic adds — no locks,
no allocations, no cache-line ping-pong beyond the unavoidable counter
updates.

Wire the registry into AbstractServerSocketInterface::processCommandContainer()
so every processed command is attributed with its container's wall-clock
time. When a container exceeds metrics/slow_command_ms (default 500),
a warning is logged including the connected username.

Add an EventLoopWatchdog heartbeat that runs on every socket pool thread.
If a heartbeat overshoots metrics/stall_warn_ms (default 2000 ms), the
overshoot is recorded in atomic counters and a warning is logged. Both
thresholds are configurable in servatrice.ini; setting stall_warn_ms to 0
disables the watchdogs entirely.

Track game-start durations via a separate histogram in MetricsRegistry.
Server_Game::startGameNow() measures the time from zone creation through
player materialization and reports it via Server::observeGameStartDurationMs().

Add a live card-count gauge: Server_Game exposes getCardsInGame() and
Servatrice::getCardsInGamesTotal() sums across all running games under
the appropriate read locks.

Include a standalone metrics_registry_test (Google Test) that validates
empty registries, single/multi-sample histograms, kind encoding,
overflow-slot collapse, negative-duration clamping, gauge rendering,
and the game-start histogram separation.

Took 10 minutes

* [Client/Server/Protocol] Surface live metrics in the Developer tab

Extend Response_GetServerStats with live counters from the in-process
MetricsRegistry: cards in games, event loop stall totals/worst,
total commands processed, average command time, active command types,
and game-start count/duration. Add a repeated CommandStats message
carrying per-command breakdowns (kind, extension number, resolved
protobuf name, count, total ms) for every type that has seen at
least one sample.

Server-side cmdGetServerStats() populates all new fields after the
existing DB uptime snapshot query, resolving protobuf extension names
via the descriptor pool for human-readable labels like
session/Command_Ping.

Expand TabDeveloper with two tables: an overview section (existing
DB stats plus the new live metrics) and a per-command breakdown table
(Command / Count / Total ms / Avg ms) sorted by total_ms descending
so the hottest commands surface first.

Took 55 minutes

Took 47 seconds

* [Server] Drop dead Prometheus histogram, add developer command metrics, fix watchdog init order

- metrics_registry: remove toPrometheusText/appendCumulativeBuckets and the time-bucket histogram that nothing in production ever emitted (the future /metrics exporter can bring it back); keep counts/totals read by the Developer tab
- Fix +Inf bucket routing that never incremented, and its test that locked the bug in
- Instrument developer_command container (kind 6) in processCommandContainer and stats label resolution
- Read metrics/{slow_command_ms,stall_warn_ms} at the top of initServer() so stall_warn_ms=0 disables the watchdogs before pool threads start
- Shrink KindStride to 1280 (largest extension in use is 1206) with a static_assert; document scrape cost of getCardsInGamesTotal; note slow_command logging has no rate limit in servatrice.ini.example

* [Tests] Give metrics_registry_test an explicit main

* [Server] Record only the dispatched command family; drop unused totals

processCommandContainer recorded every family in a container even though
the base if/else-if dispatch processes at most one. An unauthenticated
client could batch a session command (login) with fabricated developer,
moderator, and admin entries and forge genuine-looking samples that were
never executed or authorized. Mirror the base's selection, skip when the
handler was already deleted, and skip entries whose extension number is
-1 (which would otherwise wrap into the previous kind's id range).

[Server] Drop dead process-lifetime byte/uptime counters

txBytesTotal/rxBytesTotal added an atomic RMW to every socket write and
read for counters nothing consumes (cmdGetServerStats fills tx_bytes,
rx_bytes, and uptime_secs from the DB snapshot). Remove the two atomics
and the getTxBytesTotal/getRxBytesTotal/getUptimeSeconds getters; the
incTxBytes/incRxBytes slots and mutexes remain for the ISL legacy
counters.

[Protocol] Document kind 5 as developer in CommandStats

NumKinds is 6 and the server emits kind_index = 5 for developer
commands; the comment stopped at 4.

* [Client] Togglable auto-refresh for Developer stats tab

* [Oracle] Fix clang-format alignment of card type priority list

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-11 17:18:56 +02:00