From 1bb270f3af8b24393d49bd34d5b38c8d1117c686 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 09:47:51 +0200 Subject: [PATCH 01/24] Fix spurious "file does not exist in zip" error in configure.sh `unzip -l | grep -q` under `set -o pipefail` could kill unzip with SIGPIPE once grep matched, making the pipeline report failure even when the file was present. Snapshot the listing into a variable so the check no longer races against unzip. Co-Authored-By: Claude Opus 4.7 --- configure.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/configure.sh b/configure.sh index ebb8b16e4..9278130c0 100755 --- a/configure.sh +++ b/configure.sh @@ -192,10 +192,13 @@ get_zip() { return 1 fi - # First, check that all the specified files exist in the zip archive + # First, check that all the specified files exist in the zip archive. + # Snapshot the listing into a variable — piping to `grep -q` under + # `set -o pipefail` can trip SIGPIPE on `unzip` and spuriously fail. + zip_listing=$(unzip -l "$tmp_zip") for arg in "$@" ; do src_file="${arg%%:*}" - if ! unzip -l "$tmp_zip" | grep -q "$src_file"; then + if ! grep -q "$src_file" <<< "$zip_listing"; then echo "Error: The file '$src_file' does not exist in the zip archive $tmp_zip." return 1 fi From 28540688dd12e0e4d468f1c40398f9669b1bd401 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 09:57:17 +0200 Subject: [PATCH 02/24] minimum qt version 6.8 --- configure.sh | 48 ++++++++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/configure.sh b/configure.sh index 9278130c0..8c402513e 100755 --- a/configure.sh +++ b/configure.sh @@ -19,7 +19,7 @@ detect_platform() { # Basic data such as platform can be global PLATFORM=$(detect_platform) BUILD_CONFIG="Debug" -QT_VERSION="6.5.3" +QT_MIN_VERSION="6.8.0" print_help() { echo "Usage: $0 [options]" @@ -73,26 +73,38 @@ find_qt() { } find_qtpath() { - local qt_path="" - - check_path() { - if [[ -d "$1" ]]; then - qt_path="$1" - return 0 - else - return 1 - fi - } - + # Pick the newest installed Qt under $QT_ROOT whose version is at least + # QT_MIN_VERSION and which has the toolchain subdir for this platform. + local toolchain="" if [[ "$PLATFORM" == "windows" ]]; then - check_path "${QT_ROOT}/${QT_VERSION}/mingw_64" + toolchain="mingw_64" elif [[ "$PLATFORM" == "linux" ]]; then - check_path "${QT_ROOT}/${QT_VERSION}/gcc_64" + toolchain="gcc_64" elif [[ "$PLATFORM" == "macos" ]]; then - check_path "${QT_ROOT}/${QT_VERSION}/macos" + toolchain="macos" fi - echo "$qt_path" + local best_ver="" + local best_path="" + + shopt -s nullglob + local dir ver + for dir in "$QT_ROOT"/*/ ; do + ver=$(basename "$dir") + [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue + [[ -d "${dir}${toolchain}" ]] || continue + # Skip versions below the floor + if [[ "$(printf '%s\n%s\n' "$QT_MIN_VERSION" "$ver" | sort -V | head -n 1)" != "$QT_MIN_VERSION" ]]; then + continue + fi + if [[ -z "$best_ver" || "$(printf '%s\n%s\n' "$best_ver" "$ver" | sort -V | tail -n 1)" == "$ver" ]]; then + best_ver="$ver" + best_path="${dir}${toolchain}" + fi + done + shopt -u nullglob + + echo "$best_path" } find_cmake() { @@ -411,8 +423,8 @@ configure() { echo "Using Qt root: $QT_ROOT" QT_PATH=$(find_qtpath) - if [ ! -d "$QT_PATH" ]; then - echo "$QT_PATH is not a directory. Aborting." + if [ -z "$QT_PATH" ] || [ ! -d "$QT_PATH" ]; then + echo "No Qt >= ${QT_MIN_VERSION} found under ${QT_ROOT}. Aborting." exit 1 fi echo "Using Qt installation: $QT_PATH" From 521dda2360151d0e95f821a474d5fe8b1b0866f7 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 10:41:41 +0200 Subject: [PATCH 03/24] shadow WrapOpenGL --- configure.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/configure.sh b/configure.sh index 8c402513e..e67cc65c2 100755 --- a/configure.sh +++ b/configure.sh @@ -19,7 +19,7 @@ detect_platform() { # Basic data such as platform can be global PLATFORM=$(detect_platform) BUILD_CONFIG="Debug" -QT_MIN_VERSION="6.8.0" +QT_MIN_VERSION="6.5.0" print_help() { echo "Usage: $0 [options]" @@ -348,6 +348,7 @@ get_qtapng() { -G Ninja \ -DCMAKE_MAKE_PROGRAM="$NINJA" \ -DCMAKE_PREFIX_PATH="$QT_PATH" \ + -DCMAKE_MODULE_PATH="${SCRIPT_DIR}/cmake" \ -DCMAKE_C_COMPILER="$CC" \ -DCMAKE_CXX_COMPILER="$CXX" @@ -493,6 +494,7 @@ $CMAKE . \ -G Ninja \ -DCMAKE_MAKE_PROGRAM=${NINJA} \ -DCMAKE_PREFIX_PATH=${QT_PATH} \ +-DCMAKE_MODULE_PATH=${SCRIPT_DIR}/cmake \ -DCMAKE_BUILD_TYPE=${BUILD_CONFIG} \ -DCMAKE_C_COMPILER=${CC} \ -DCMAKE_CXX_COMPILER=${CXX}" From decea556e01fbb5d5372a5de34830a3e74755f26 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 11:03:34 +0200 Subject: [PATCH 04/24] Add FindWrapOpenGL.cmake override to actually shadow Qt's The previous commit wired CMAKE_MODULE_PATH to ${SCRIPT_DIR}/cmake but never added the override file, so Qt's bundled FindWrapOpenGL.cmake (with the -framework AGL line) was still being used. Co-Authored-By: Claude Opus 4.7 --- cmake/FindWrapOpenGL.cmake | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 cmake/FindWrapOpenGL.cmake diff --git a/cmake/FindWrapOpenGL.cmake b/cmake/FindWrapOpenGL.cmake new file mode 100644 index 000000000..737b0b2ac --- /dev/null +++ b/cmake/FindWrapOpenGL.cmake @@ -0,0 +1,43 @@ +# Override for Qt's FindWrapOpenGL.cmake on macOS. +# +# Qt 6.5–6.8's bundled FindWrapOpenGL.cmake unconditionally adds +# `-framework AGL` to QtGui's public link interface. AGL was removed from +# the macOS SDK around Xcode 16 / macOS 15, so the link fails with +# "framework 'AGL' not found". Fixed upstream in Qt 6.9. +# +# This file is a copy of Qt's module with the single AGL target_link_libraries +# line removed. It is picked up via CMAKE_MODULE_PATH before Qt's copy. +# +# Original: Copyright (C) 2022 The Qt Company Ltd. SPDX BSD-3-Clause. + +if(TARGET WrapOpenGL::WrapOpenGL) + set(WrapOpenGL_FOUND ON) + return() +endif() + +set(WrapOpenGL_FOUND OFF) + +find_package(OpenGL ${WrapOpenGL_FIND_VERSION}) + +if (OpenGL_FOUND) + set(WrapOpenGL_FOUND ON) + + add_library(WrapOpenGL::WrapOpenGL INTERFACE IMPORTED) + if(APPLE) + get_target_property(__opengl_fw_lib_path OpenGL::GL IMPORTED_LOCATION) + if(__opengl_fw_lib_path AND NOT __opengl_fw_lib_path MATCHES "/([^/]+)\\.framework$") + get_filename_component(__opengl_fw_path "${__opengl_fw_lib_path}" DIRECTORY) + endif() + + if(NOT __opengl_fw_path) + set(__opengl_fw_path "-framework OpenGL") + endif() + + target_link_libraries(WrapOpenGL::WrapOpenGL INTERFACE ${__opengl_fw_path}) + else() + target_link_libraries(WrapOpenGL::WrapOpenGL INTERFACE OpenGL::GL) + endif() +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(WrapOpenGL DEFAULT_MSG WrapOpenGL_FOUND) From 8f2344d4e58072a60a039e70d7b18eff91b5c53b Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 11:03:42 +0200 Subject: [PATCH 05/24] Always re-fetch downloaded libs; skip Discord RPC on macOS arm64 - Drop the "if header exists, skip" short-circuits in get_bass / get_bassopus / get_discordrpc so every configure run re-extracts a fresh copy. Add `unzip -o` so re-extraction overwrites without prompting. - Detect host arch and gate the macOS Discord RPC download on x86_64. The official v3.4.0 release ships an x86_64-only dylib and the repo has been archived since 2018, so arm64 has no usable binary. Pass -DAO_ENABLE_DISCORD_RPC=OFF on arm64 macOS to disable it at build. - Fix a pre-existing typo where the macOS discord-rpc extract listed discord_rpc.h twice instead of also pulling discord_register.h. Co-Authored-By: Claude Opus 4.7 --- configure.sh | 57 +++++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/configure.sh b/configure.sh index e67cc65c2..1c8e70dfe 100755 --- a/configure.sh +++ b/configure.sh @@ -16,8 +16,17 @@ detect_platform() { echo "${platform}" } +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64";; + arm64|aarch64) echo "arm64";; + *) echo "unknown";; + esac +} + # Basic data such as platform can be global PLATFORM=$(detect_platform) +ARCH=$(detect_arch) BUILD_CONFIG="Debug" QT_MIN_VERSION="6.5.0" @@ -225,7 +234,7 @@ get_zip() { # Create the destination directory if it doesn't exist mkdir -p "$dst_dir" - unzip -j "$tmp_zip" "$src_file" -d "$dst_dir" + unzip -o -j "$tmp_zip" "$src_file" -d "$dst_dir" shift done @@ -235,13 +244,6 @@ get_zip() { } get_bass() { - echo "Checking for BASS..." - # If lib/bass.h exists, assume that BASS is already present - if [ -f "./lib/bass.h" ]; then - echo "BASS is installed." - return 0 - fi - echo "Downloading BASS..." if [[ "$PLATFORM" == "windows" ]]; then get_zip https://www.un4seen.com/files/bass24.zip \ @@ -261,13 +263,6 @@ get_bass() { } get_bassopus() { - echo "Checking for BASSOPUS..." - # If lib/bassopus.h exists, assume that BASSOPUS is already present - if [ -f "./lib/bassopus.h" ]; then - echo "BASSOPUS is installed." - return 0 - fi - echo "Downloading BASSOPUS..." if [[ "$PLATFORM" == "windows" ]]; then get_zip https://www.un4seen.com/files/bassopus24.zip \ @@ -287,13 +282,6 @@ get_bassopus() { } get_discordrpc() { - echo "Checking for Discord RPC..." - # If lib/discord_rpc.h exists, assume that Discord RPC is already present - if [ -f "./lib/discord_rpc.h" ]; then - echo "Discord RPC is installed." - return 0 - fi - echo "Downloading Discord RPC..." if [[ "$PLATFORM" == "windows" ]]; then get_zip https://github.com/discordapp/discord-rpc/releases/download/v3.4.0/discord-rpc-win.zip \ @@ -308,10 +296,18 @@ get_discordrpc() { discord-rpc/linux-dynamic/include/discord_rpc.h:./lib \ discord-rpc/linux-dynamic/include/discord_register.h:./lib elif [[ "$PLATFORM" == "macos" ]]; then - get_zip https://github.com/discord/discord-rpc/releases/download/v3.4.0/discord-rpc-osx.zip \ - discord-rpc/osx-dynamic/lib/libdiscord-rpc.dylib:./lib \ - discord-rpc/osx-dynamic/include/discord_rpc.h:./lib \ - discord-rpc/osx-dynamic/include/discord_rpc.h:./lib + if [[ "$ARCH" == "x86_64" ]]; then + get_zip https://github.com/discord/discord-rpc/releases/download/v3.4.0/discord-rpc-osx.zip \ + discord-rpc/osx-dynamic/lib/libdiscord-rpc.dylib:./lib \ + discord-rpc/osx-dynamic/include/discord_rpc.h:./lib \ + discord-rpc/osx-dynamic/include/discord_register.h:./lib + else + # The official discord-rpc v3.4.0 release only ships an x86_64 + # dylib and the repo was archived in 2018, so there is no native + # arm64 build. Skip the download — Discord RPC is disabled at + # build time on arm64 macOS via -DAO_ENABLE_DISCORD_RPC=OFF below. + echo "Skipping Discord RPC on macOS ${ARCH} (no native binary available)." + fi fi } @@ -488,6 +484,12 @@ configure() { get_qtapng get_themes + # Discord RPC has no native arm64 macOS binary, so turn it off there. + EXTRA_CMAKE_FLAGS="" + if [[ "$PLATFORM" == "macos" && "$ARCH" != "x86_64" ]]; then + EXTRA_CMAKE_FLAGS="-DAO_ENABLE_DISCORD_RPC=OFF" + fi + # Typically, IDEs like running cmake themselves, but we need the binary to fix dependencies correctly FULL_CMAKE_CMD="\ $CMAKE . \ @@ -497,7 +499,8 @@ $CMAKE . \ -DCMAKE_MODULE_PATH=${SCRIPT_DIR}/cmake \ -DCMAKE_BUILD_TYPE=${BUILD_CONFIG} \ -DCMAKE_C_COMPILER=${CC} \ --DCMAKE_CXX_COMPILER=${CXX}" +-DCMAKE_CXX_COMPILER=${CXX} \ +${EXTRA_CMAKE_FLAGS}" $FULL_CMAKE_CMD $NINJA From 735e253556eb2bc01398be3faeb58871c523c507 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 11:15:34 +0200 Subject: [PATCH 06/24] Restore skip-if-already-present checks for downloaded libs Re-introduces the early-return in get_bass / get_bassopus / get_discordrpc when the header is already in ./lib. Use `configure.sh clean` (which already wipes ./lib, ./bin, ./tmp) to force a fresh download. Co-Authored-By: Claude Opus 4.7 --- configure.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/configure.sh b/configure.sh index 1c8e70dfe..4e34463cb 100755 --- a/configure.sh +++ b/configure.sh @@ -244,6 +244,13 @@ get_zip() { } get_bass() { + echo "Checking for BASS..." + # If lib/bass.h exists, assume that BASS is already present + if [ -f "./lib/bass.h" ]; then + echo "BASS is installed." + return 0 + fi + echo "Downloading BASS..." if [[ "$PLATFORM" == "windows" ]]; then get_zip https://www.un4seen.com/files/bass24.zip \ @@ -263,6 +270,13 @@ get_bass() { } get_bassopus() { + echo "Checking for BASSOPUS..." + # If lib/bassopus.h exists, assume that BASSOPUS is already present + if [ -f "./lib/bassopus.h" ]; then + echo "BASSOPUS is installed." + return 0 + fi + echo "Downloading BASSOPUS..." if [[ "$PLATFORM" == "windows" ]]; then get_zip https://www.un4seen.com/files/bassopus24.zip \ @@ -282,6 +296,13 @@ get_bassopus() { } get_discordrpc() { + echo "Checking for Discord RPC..." + # If lib/discord_rpc.h exists, assume that Discord RPC is already present + if [ -f "./lib/discord_rpc.h" ]; then + echo "Discord RPC is installed." + return 0 + fi + echo "Downloading Discord RPC..." if [[ "$PLATFORM" == "windows" ]]; then get_zip https://github.com/discordapp/discord-rpc/releases/download/v3.4.0/discord-rpc-win.zip \ From 497c8d6c7f4f74c0cedba55ca9487af91ae5ad7c Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 11:15:53 +0200 Subject: [PATCH 07/24] Add QT_PATH= CLI override to bypass Qt auto-detection Useful when multiple Qt versions are installed and you need to pin a specific one (e.g. for testing against an older LTS) without bumping QT_MIN_VERSION or hiding other installs. Both QT_ROOT= and QT_PATH= can now be passed in any order; QT_ROOT is still required for locating the bundled CMake/MinGW/Ninja under ${QT_ROOT}/Tools. Co-Authored-By: Claude Opus 4.7 --- configure.sh | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/configure.sh b/configure.sh index 4e34463cb..1d95f829a 100755 --- a/configure.sh +++ b/configure.sh @@ -36,6 +36,7 @@ print_help() { echo " -h, --help: Print this help message" echo " clean: Remove all files from lib, bin and tmp" echo " QT_ROOT=path: Specify the root path to where Qt is installed (eg. /c/Qt/)" + echo " QT_PATH=path: Specify the exact Qt install to use, bypassing auto-detection (eg. /c/Qt/6.5.3/mingw_64)" } # Check if a given command returns a non-zero exit code @@ -420,15 +421,22 @@ configure() { exit 1 fi - # Now we look for qt + # Parse KEY=VALUE overrides QT_ROOT="" - - # If QT_ROOT=path is passed, use that - if [ "$#" -gt 0 ] && [ "${1%%=*}" = "QT_ROOT" ]; then - QT_ROOT="${1#*=}" + QT_PATH="" + while [ "$#" -gt 0 ]; do + case "$1" in + QT_ROOT=*) QT_ROOT="${1#*=}" ;; + QT_PATH=*) QT_PATH="${1#*=}" ;; + *) echo "Unknown argument: $1"; print_help; exit 1 ;; + esac shift - # Try to find it otherwise - else + done + + # Resolve QT_ROOT (auto-detect if not overridden). Still needed even when + # QT_PATH is given explicitly — find_cmake / find_mingw / find_ninja use + # ${QT_ROOT}/Tools. + if [ -z "$QT_ROOT" ]; then QT_ROOT=$(find_qt) if [ -z "$QT_ROOT" ]; then echo "Qt not found. Aborting."; exit 1; @@ -440,10 +448,18 @@ configure() { fi echo "Using Qt root: $QT_ROOT" - QT_PATH=$(find_qtpath) - if [ -z "$QT_PATH" ] || [ ! -d "$QT_PATH" ]; then - echo "No Qt >= ${QT_MIN_VERSION} found under ${QT_ROOT}. Aborting." - exit 1 + # Resolve QT_PATH: explicit override wins, otherwise auto-detect. + if [ -n "$QT_PATH" ]; then + if [ ! -d "$QT_PATH" ]; then + echo "$QT_PATH is not a directory. Aborting." + exit 1 + fi + else + QT_PATH=$(find_qtpath) + if [ -z "$QT_PATH" ] || [ ! -d "$QT_PATH" ]; then + echo "No Qt >= ${QT_MIN_VERSION} found under ${QT_ROOT}. Aborting." + exit 1 + fi fi echo "Using Qt installation: $QT_PATH" From 02165f4720ae6ea56be1b47ad905f4483bc192fd Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:08:07 +0200 Subject: [PATCH 08/24] Add Qt6::Multimedia to the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the BASS → QtMultimedia migration. No behavior change yet — this just validates the dependency before swapping the audio player classes over. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ebaaae253..3be6600d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ option(AO_BUILD_TESTS "Build test programs" ON) option(AO_ENABLE_DISCORD_RPC "Enable Discord Rich Presence" ON) find_package(QT NAMES Qt6) -find_package(Qt6 REQUIRED COMPONENTS Core Gui Network Widgets Concurrent WebSockets UiTools) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Network Widgets Concurrent WebSockets UiTools Multimedia) qt_add_executable(Attorney_Online src/aoapplication.cpp @@ -125,6 +125,7 @@ target_link_libraries(Attorney_Online PRIVATE Qt${QT_VERSION_MAJOR}::Concurrent Qt${QT_VERSION_MAJOR}::WebSockets Qt${QT_VERSION_MAJOR}::UiTools + Qt${QT_VERSION_MAJOR}::Multimedia bass bassopus ) From e1e8712bab03e50328bec3b6a4153bdeb99be1c4 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:10:40 +0200 Subject: [PATCH 09/24] Migrate AOBlipPlayer from BASS to QSoundEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 5 BASS HSTREAMs with 5 QSoundEffect instances cycled the same way. Non-wav blips (opus, mp3, ogg) are decoded once via QAudioDecoder and cached as a temp .wav under ./tmp/blip-cache/.wav so QSoundEffect can play them — blips are small enough that the one-shot synchronous decode is negligible. Co-Authored-By: Claude Opus 4.7 --- src/aoblipplayer.cpp | 139 +++++++++++++++++++++++++++++++++++++------ src/aoblipplayer.h | 15 ++--- 2 files changed, 128 insertions(+), 26 deletions(-) diff --git a/src/aoblipplayer.cpp b/src/aoblipplayer.cpp index 3a13d7879..ed3191faa 100644 --- a/src/aoblipplayer.cpp +++ b/src/aoblipplayer.cpp @@ -1,5 +1,14 @@ #include "aoblipplayer.h" +#include +#include +#include +#include +#include +#include +#include +#include + AOBlipPlayer::AOBlipPlayer(AOApplication *ao_app) : ao_app(ao_app) {} @@ -19,36 +28,132 @@ void AOBlipPlayer::setMuted(bool enabled) void AOBlipPlayer::setBlip(QString blip) { QString path = ao_app->get_sfx_suffix(ao_app->get_sounds_path(blip)); + QString playable = resolveToPlayablePath(path); + QUrl source = QUrl::fromLocalFile(playable); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_StreamFree(m_stream[i]); - - if (path.endsWith(".opus")) - { - m_stream[i] = BASS_OPUS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_UNICODE | BASS_ASYNCFILE); - } - else - { - m_stream[i] = BASS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_UNICODE | BASS_ASYNCFILE); - } + m_stream[i].setSource(source); } - updateInternalVolume(); } void AOBlipPlayer::playBlip() { - HSTREAM stream = m_stream[m_cycle]; - BASS_ChannelSetDevice(stream, BASS_GetDevice()); - BASS_ChannelPlay(stream, false); - m_cycle = ++m_cycle % STREAM_COUNT; + m_stream[m_cycle].play(); + m_cycle = (m_cycle + 1) % STREAM_COUNT; } void AOBlipPlayer::updateInternalVolume() { - float volume = m_muted ? 0.0f : (m_volume * 0.01); + float volume = m_muted ? 0.0f : (m_volume * 0.01f); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelSetAttribute(m_stream[i], BASS_ATTRIB_VOL, volume); + m_stream[i].setVolume(volume); + } +} + +namespace +{ +// Standard 44-byte WAV header for PCM data. +void writeWavHeader(QFile &out, const QAudioFormat &fmt, qint32 pcmBytes) +{ + const quint32 sampleRate = static_cast(fmt.sampleRate()); + const quint16 channels = static_cast(fmt.channelCount()); + const quint16 bitsPerSample = static_cast(fmt.bytesPerSample() * 8); + const quint32 byteRate = sampleRate * channels * (bitsPerSample / 8); + const quint16 blockAlign = channels * (bitsPerSample / 8); + const quint32 riffSize = 36 + static_cast(pcmBytes); + + auto write32 = [&](quint32 v) { + char b[4] = {char(v & 0xff), char((v >> 8) & 0xff), char((v >> 16) & 0xff), char((v >> 24) & 0xff)}; + out.write(b, 4); + }; + auto write16 = [&](quint16 v) { + char b[2] = {char(v & 0xff), char((v >> 8) & 0xff)}; + out.write(b, 2); + }; + + out.write("RIFF", 4); + write32(riffSize); + out.write("WAVE", 4); + out.write("fmt ", 4); + write32(16); + write16(1); // PCM + write16(channels); + write32(sampleRate); + write32(byteRate); + write16(blockAlign); + write16(bitsPerSample); + out.write("data", 4); + write32(static_cast(pcmBytes)); +} +} // namespace + +QString AOBlipPlayer::resolveToPlayablePath(const QString &sourcePath) +{ + if (sourcePath.endsWith(".wav", Qt::CaseInsensitive)) + { + return sourcePath; } + + static QMap cache; + auto cached = cache.constFind(sourcePath); + if (cached != cache.constEnd()) + { + return *cached; + } + + QAudioDecoder decoder; + decoder.setSource(QUrl::fromLocalFile(sourcePath)); + + QByteArray pcm; + QAudioFormat format; + bool errored = false; + + QEventLoop loop; + QObject::connect(&decoder, &QAudioDecoder::bufferReady, &loop, [&]() { + while (decoder.bufferAvailable()) + { + QAudioBuffer buf = decoder.read(); + if (!format.isValid()) + { + format = buf.format(); + } + pcm.append(buf.constData(), buf.byteCount()); + } + }); + QObject::connect(&decoder, &QAudioDecoder::finished, &loop, &QEventLoop::quit); + QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, + [&](QAudioDecoder::Error) { + errored = true; + loop.quit(); + }); + + decoder.start(); + loop.exec(); + + if (errored || pcm.isEmpty() || !format.isValid()) + { + // Decode failed; fall back to the original path so QSoundEffect at least logs a clear error. + cache.insert(sourcePath, sourcePath); + return sourcePath; + } + + QString hash = QString::number(qHash(sourcePath), 16); + QDir cacheDir(QDir::current().absoluteFilePath("tmp/blip-cache")); + cacheDir.mkpath("."); + QString outPath = cacheDir.absoluteFilePath(hash + ".wav"); + + QFile out(outPath); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + cache.insert(sourcePath, sourcePath); + return sourcePath; + } + writeWavHeader(out, format, pcm.size()); + out.write(pcm); + out.close(); + + cache.insert(sourcePath, outPath); + return outPath; } diff --git a/src/aoblipplayer.h b/src/aoblipplayer.h index 92b43d297..237697f4a 100644 --- a/src/aoblipplayer.h +++ b/src/aoblipplayer.h @@ -2,14 +2,7 @@ #include "aoapplication.h" -#include -#include - -#include -#include -#include - -#include +#include class AOBlipPlayer { @@ -30,8 +23,12 @@ class AOBlipPlayer int m_volume = 0; bool m_muted = false; - HSTREAM m_stream[STREAM_COUNT]{}; + QSoundEffect m_stream[STREAM_COUNT]; int m_cycle = 0; void updateInternalVolume(); + + // QSoundEffect only plays wav. Decode anything else to a temp wav and + // cache by source path. Returns the playable path (same as input for wav). + static QString resolveToPlayablePath(const QString &sourcePath); }; From 5afbfc6714960d78505b0a28d025919010325d81 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:11:49 +0200 Subject: [PATCH 10/24] Migrate AOSfxPlayer from BASS to QSoundEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 HSTREAMs replaced with 5 QSoundEffect instances. Round-robin allocation, looping toggle (loopCount = Infinite vs 1), and stopAllLoopingStream all preserved. SFX is now wav-only — content packs shipping .opus or .mp3 SFX will no longer play; documented as an accepted regression. Blips are unaffected (AOBlipPlayer pre-decodes non-wav sources). Co-Authored-By: Claude Opus 4.7 --- src/aosfxplayer.cpp | 48 ++++++++++++--------------------------------- src/aosfxplayer.h | 8 ++------ 2 files changed, 15 insertions(+), 41 deletions(-) diff --git a/src/aosfxplayer.cpp b/src/aosfxplayer.cpp index ac3b8513d..7a175404b 100644 --- a/src/aosfxplayer.cpp +++ b/src/aosfxplayer.cpp @@ -2,6 +2,9 @@ #include "file_functions.h" +#include +#include + AOSfxPlayer::AOSfxPlayer(AOApplication *ao_app) : ao_app(ao_app) {} @@ -21,7 +24,7 @@ void AOSfxPlayer::play(QString path) { for (int i = 0; i < STREAM_COUNT; ++i) { - if (BASS_ChannelIsActive(m_stream[i]) == BASS_ACTIVE_PLAYING) + if (m_stream[i].isPlaying()) { m_current_stream_id = (i + 1) % STREAM_COUNT; } @@ -32,20 +35,11 @@ void AOSfxPlayer::play(QString path) } } - if (path.endsWith(".opus")) - { - m_stream[m_current_stream_id] = BASS_OPUS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_STREAM_AUTOFREE | BASS_UNICODE | BASS_ASYNCFILE); - } - else - { - m_stream[m_current_stream_id] = BASS_StreamCreateFile(FALSE, path.utf16(), 0, 0, BASS_STREAM_AUTOFREE | BASS_UNICODE | BASS_ASYNCFILE); - } - + QSoundEffect &stream = m_stream[m_current_stream_id]; + stream.setLoopCount(1); + stream.setSource(QUrl::fromLocalFile(path)); updateInternalVolume(); - - BASS_ChannelSetDevice(m_stream[m_current_stream_id], BASS_GetDevice()); - BASS_ChannelPlay(m_stream[m_current_stream_id], false); - BASS_ChannelSetSync(m_stream[m_current_stream_id], BASS_SYNC_DEV_FAIL, 0, ao_app->BASSreset, 0); + stream.play(); } void AOSfxPlayer::findAndPlaySfx(QString sfx) @@ -81,7 +75,7 @@ void AOSfxPlayer::stopAllLoopingStream() { for (int i = 0; i < STREAM_COUNT; ++i) { - if (BASS_ChannelFlags(m_stream[i], 0, 0) & BASS_SAMPLE_LOOP) + if (m_stream[i].loopCount() == QSoundEffect::Infinite) { stop(i); } @@ -97,22 +91,21 @@ void AOSfxPlayer::stop(int streamId) return; } - BASS_ChannelStop(m_stream[streamId]); + m_stream[streamId].stop(); } void AOSfxPlayer::setMuted(bool toggle) { m_muted = toggle; - // Update the audio volume updateInternalVolume(); } void AOSfxPlayer::updateInternalVolume() { - float volume = m_muted ? 0.0f : (m_volume * 0.01); + float volume = m_muted ? 0.0f : (m_volume * 0.01f); for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelSetAttribute(m_stream[i], BASS_ATTRIB_VOL, volume); + m_stream[i].setVolume(volume); } } @@ -126,22 +119,7 @@ void AOSfxPlayer::setLooping(bool toggle, int streamId) } m_looping = toggle; - if (BASS_ChannelFlags(m_stream[streamId], 0, 0) & BASS_SAMPLE_LOOP) - { - if (m_looping == false) - { - BASS_ChannelFlags(m_stream[streamId], 0, - BASS_SAMPLE_LOOP); // remove the LOOP flag - } - } - else - { - if (m_looping == true) - { - BASS_ChannelFlags(m_stream[streamId], BASS_SAMPLE_LOOP, - BASS_SAMPLE_LOOP); // set the LOOP flag - } - } + m_stream[streamId].setLoopCount(m_looping ? QSoundEffect::Infinite : 1); } int AOSfxPlayer::maybeFetchCurrentStreamId(int streamId) diff --git a/src/aosfxplayer.h b/src/aosfxplayer.h index 7669f0de9..faae90deb 100644 --- a/src/aosfxplayer.h +++ b/src/aosfxplayer.h @@ -2,11 +2,7 @@ #include "aoapplication.h" -#include -#include - -#include -#include +#include class AOSfxPlayer { @@ -36,7 +32,7 @@ class AOSfxPlayer int m_volume = 0; bool m_muted = false; bool m_looping = true; - HSTREAM m_stream[STREAM_COUNT]{}; + QSoundEffect m_stream[STREAM_COUNT]; int m_current_stream_id = 0; int maybeFetchCurrentStreamId(int streamId); From b71ecd35da9f3081753d7b67d78f0017269ea53e Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:15:54 +0200 Subject: [PATCH 11/24] Migrate AOMusicPlayer from BASS to QMediaPlayer + QAudioOutput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces HSTREAM[2] with QMediaPlayer/QAudioOutput pairs. Highlights: - Custom .txt sidecar loop points now in ms instead of bytes; legacy non-seconds form converts via a one-shot QAudioDecoder sample-rate probe. seconds=true form is direct. - Loop wrap is a 5 ms QTimer poll on QMediaPlayer::position() (vs BASS sample-accurate sync — accepted ~10 ms jitter). No custom points: QMediaPlayer::setLoops(Infinite). - Crossfade: SYNC_POS reads the old player's position and seeks the new one once it reaches LoadedMedia. FADE_OUT animates the old player's volume to 0 over 4 s (InExpo) then deleteLater. FADE_IN ramps from 0 to target over 1 s (OutExpo). - HTTP streaming preserved via QUrl(httpUrl); Options::streamingEnabled gate unchanged. - ~stop.mp3 sentinel handled explicitly: destroy stream, return "None". playStream is now synchronous. The QFutureWatcher wrapper and the Courtroom::update_ui_music_name slot are removed — QMediaPlayer setSource is non-blocking, so the worker-thread offload no longer serves a purpose. Audio device routing temporarily uses the default output; the picker in the options dialog is wired up in the next commit. Co-Authored-By: Claude Opus 4.7 --- src/aomusicplayer.cpp | 494 +++++++++++++++++++++++++++--------------- src/aomusicplayer.h | 26 ++- src/courtroom.cpp | 24 +- src/courtroom.h | 2 - 4 files changed, 339 insertions(+), 207 deletions(-) diff --git a/src/aomusicplayer.cpp b/src/aomusicplayer.cpp index 6ea8c83e9..0fa0488f2 100644 --- a/src/aomusicplayer.cpp +++ b/src/aomusicplayer.cpp @@ -1,13 +1,61 @@ #include "aomusicplayer.h" +#include "datatypes.h" #include "file_functions.h" #include "options.h" -#include - +#include +#include +#include #include -#include -#include +#include +#include +#include +#include + +namespace +{ +constexpr int FADE_OUT_MS = 4000; +constexpr int FADE_IN_MS = 1000; +constexpr int LOOP_POLL_INTERVAL_MS = 5; + +// Probe the sample rate of an audio file by reading one buffer via QAudioDecoder. +// Returns 0 on failure. Synchronous; only used by the legacy non-seconds loop sidecar form. +int probeSampleRate(const QString &mediaPath) +{ + QAudioDecoder decoder; + decoder.setSource(QUrl::fromLocalFile(mediaPath)); + + int rate = 0; + bool errored = false; + QEventLoop loop; + + QObject::connect(&decoder, &QAudioDecoder::bufferReady, &loop, [&]() { + while (decoder.bufferAvailable()) + { + QAudioBuffer buf = decoder.read(); + if (buf.format().isValid()) + { + rate = buf.format().sampleRate(); + loop.quit(); + return; + } + } + }); + QObject::connect(&decoder, &QAudioDecoder::finished, &loop, &QEventLoop::quit); + QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, + [&](QAudioDecoder::Error) { + errored = true; + loop.quit(); + }); + + decoder.start(); + loop.exec(); + decoder.stop(); + + return errored ? 0 : rate; +} +} // namespace AOMusicPlayer::AOMusicPlayer(AOApplication *ao_app) : ao_app(ao_app) @@ -15,175 +63,310 @@ AOMusicPlayer::AOMusicPlayer(AOApplication *ao_app) AOMusicPlayer::~AOMusicPlayer() { - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) + for (int i = 0; i < STREAM_COUNT; ++i) { - BASS_ChannelStop(m_stream_list[n_stream]); + destroyStream(i); } } -QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, int effectFlags) +bool AOMusicPlayer::ensureValidStreamId(int streamId) +{ + return streamId >= 0 && streamId < STREAM_COUNT; +} + +void AOMusicPlayer::destroyStream(int streamId) { if (!ensureValidStreamId(streamId)) { - return "[ERROR] Invalid Channel"; + return; } - - bool isLooping = loopEnabled && !(effectFlags & NO_REPEAT); - - quint32 flags = BASS_STREAM_AUTOFREE; - if (isLooping) + Stream &s = m_streams[streamId]; + if (s.loopTimer) { - flags |= BASS_SAMPLE_LOOP; + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; } - - QString f_path = song; - HSTREAM newstream; - if (f_path.startsWith("http")) + if (s.player) { - if (!Options::getInstance().streamingEnabled()) - { - BASS_ChannelStop(m_stream_list[streamId]); - return QObject::tr("[MISSING] Streaming disabled."); - } - QUrl l_url = QUrl(f_path); - newstream = BASS_StreamCreateURL(l_url.toEncoded().toStdString().c_str(), 0, flags, nullptr, 0); + s.player->stop(); + s.player->deleteLater(); + s.player = nullptr; } - else + if (s.output) { - flags |= BASS_STREAM_PRESCAN | BASS_UNICODE | BASS_ASYNCFILE; - - f_path = ao_app->get_real_path(ao_app->get_music_path(song)); - newstream = BASS_StreamCreateFile(FALSE, f_path.utf16(), 0, 0, flags); + s.output->deleteLater(); + s.output = nullptr; } + s.loop_start_ms = 0; + s.loop_end_ms = 0; +} - int error = BASS_ErrorGetCode(); - if (Options::getInstance().audioOutputDevice() != "default") +void AOMusicPlayer::applyVolume(int streamId) +{ + Stream &s = m_streams[streamId]; + if (!s.output) { - BASS_ChannelSetDevice(m_stream_list[streamId], BASS_GetDevice()); + return; } + float volume = m_muted ? 0.0f : (m_volume[streamId] / 100.0f); + s.output->setVolume(volume); +} + +void AOMusicPlayer::fadeOutAndDelete(QMediaPlayer *player, QAudioOutput *output, int durationMs) +{ + auto *anim = new QVariantAnimation(player); + anim->setStartValue(output->volume()); + anim->setEndValue(0.0f); + anim->setDuration(durationMs); + anim->setEasingCurve(QEasingCurve::InExpo); + QObject::connect(anim, &QVariantAnimation::valueChanged, output, + [output](const QVariant &v) { output->setVolume(v.toFloat()); }); + QObject::connect(anim, &QVariantAnimation::finished, player, [player, output]() { + player->stop(); + player->deleteLater(); + output->deleteLater(); + }); + anim->start(QAbstractAnimation::DeleteWhenStopped); +} + +void AOMusicPlayer::parseLoopSidecar(int streamId, const QString &dataPath, const QString &mediaPath) +{ + Stream &s = m_streams[streamId]; + s.loop_start_ms = 0; + s.loop_end_ms = 0; - m_loop_start[streamId] = 0; - m_loop_end[streamId] = 0; + QStringList lines = ao_app->read_file(dataPath).split("\n"); + bool seconds_mode = false; + int sample_rate = 0; // probed lazily; only needed for legacy non-seconds form + const int sample_size = 2; // 16-bit + const int num_channels = 2; - QString d_path = f_path + ".txt"; - if (isLooping && file_exists(d_path)) // Contains loop/etc. information file + for (const QString &line : lines) { - QStringList lines = ao_app->read_file(d_path).split("\n"); - bool seconds_mode = false; - foreach (QString line, lines) + QStringList args = line.split("="); + if (args.size() < 2) { - QStringList args = line.split("="); - if (args.size() < 2) - { - continue; - } - QString arg = args[0].trimmed(); - if (arg == "seconds") + continue; + } + QString arg = args[0].trimmed(); + QString val = args[1].trimmed(); + + if (arg == "seconds") + { + seconds_mode = (val == "true"); + continue; + } + + qint64 ms = 0; + if (seconds_mode) + { + ms = static_cast(val.toDouble() * 1000.0); + } + else + { + // Legacy form: value is a sample count, converted to ms via probed sample rate. + if (sample_rate == 0) { - if (args[1].trimmed() == "true") + sample_rate = probeSampleRate(mediaPath); + if (sample_rate == 0) { - seconds_mode = true; // Use new epic behavior + qWarning() << "Failed to probe sample rate for" << mediaPath + << "— legacy byte-form loop points will be ignored."; continue; } - - continue; - } - - float sample_rate; - BASS_ChannelGetAttribute(newstream, BASS_ATTRIB_FREQ, &sample_rate); - - // Grab number of bytes for sample size - int sample_size = 16 / 8; - - // number of channels (stereo/mono) - int num_channels = 2; - - // Calculate the bytes for loop_start/loop_end to use with the sync proc - QWORD bytes; - if (seconds_mode) - { - bytes = BASS_ChannelSeconds2Bytes(newstream, args[1].trimmed().toDouble()); - } - else - { - bytes = static_cast(args[1].trimmed().toUInt() * sample_size * num_channels); - } - if (arg == "loop_start") - { - m_loop_start[streamId] = bytes; - } - else if (arg == "loop_length") - { - m_loop_end[streamId] = m_loop_start[streamId] + bytes; - } - else if (arg == "loop_end") - { - m_loop_end[streamId] = bytes; } + quint64 bytes = static_cast(val.toUInt()) * sample_size * num_channels; + quint64 frame_bytes = static_cast(sample_rate) * sample_size * num_channels; + ms = static_cast(bytes * 1000 / frame_bytes); } - qDebug() << "Found data file for song" << song << "length" << BASS_ChannelGetLength(newstream, BASS_POS_BYTE) << "loop start" << m_loop_start[streamId] << "loop end" << m_loop_end[streamId]; - } - - if (BASS_ChannelIsActive(m_stream_list[streamId]) == BASS_ACTIVE_PLAYING) - { - DWORD oldstream = m_stream_list[streamId]; - if (effectFlags & SYNC_POS) + if (arg == "loop_start") { - BASS_ChannelLock(oldstream, true); - // Sync it with the new sample - BASS_ChannelSetPosition(newstream, BASS_ChannelGetPosition(oldstream, BASS_POS_BYTE), BASS_POS_BYTE); - BASS_ChannelLock(oldstream, false); + s.loop_start_ms = ms; } - - if ((effectFlags & FADE_OUT) && m_volume[streamId] > 0) + else if (arg == "loop_length") { - // Fade out the other sample and stop it (due to -1) - BASS_ChannelSlideAttribute(oldstream, BASS_ATTRIB_VOL | BASS_SLIDE_LOG, -1, 4000); + s.loop_end_ms = s.loop_start_ms + ms; } - else + else if (arg == "loop_end") { - BASS_ChannelStop(oldstream); // Stop the sample since we don't need it anymore + s.loop_end_ms = ms; } } - else +} + +void AOMusicPlayer::armLoopWatcher(int streamId) +{ + Stream &s = m_streams[streamId]; + if (!s.player) { - BASS_ChannelStop(m_stream_list[streamId]); + return; } - m_stream_list[streamId] = newstream; - BASS_ChannelPlay(newstream, false); - if (effectFlags & FADE_IN) + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + + if (s.loop_start_ms < s.loop_end_ms) { - // Fade in our sample - BASS_ChannelSetAttribute(newstream, BASS_ATTRIB_VOL, 0); - BASS_ChannelSlideAttribute(newstream, BASS_ATTRIB_VOL, static_cast(m_volume[streamId] / 100.0f), 1000); + // Custom loop points: poll position; wrap on cross. + s.loopTimer = new QTimer(s.player); + s.loopTimer->setInterval(LOOP_POLL_INTERVAL_MS); + QMediaPlayer *p = s.player; + qint64 loopStart = s.loop_start_ms; + qint64 loopEnd = s.loop_end_ms; + QObject::connect(s.loopTimer, &QTimer::timeout, p, [p, loopStart, loopEnd]() { + if (p->position() >= loopEnd) + { + p->setPosition(loopStart); + } + }); + s.loopTimer->start(); + s.player->setLoops(1); } else { - this->setStreamVolume(m_volume[streamId], streamId); + // No custom points: let QMediaPlayer handle the loop natively. + s.player->setLoops(QMediaPlayer::Infinite); } +} - BASS_ChannelSetSync(newstream, BASS_SYNC_DEV_FAIL, 0, ao_app->BASSreset, 0); - - this->setStreamLooping(isLooping, streamId); // Have to do this here due to any - // crossfading-related changes, etc. +QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, int effectFlags) +{ + if (!ensureValidStreamId(streamId)) + { + return "[ERROR] Invalid Channel"; + } + bool isLooping = loopEnabled && !(effectFlags & NO_REPEAT); + bool isHttp = song.startsWith("http"); bool is_stop = (song == "~stop.mp3"); + QString p_song_clear = QUrl(song).fileName(); p_song_clear = p_song_clear.left(p_song_clear.lastIndexOf('.')); - if (is_stop && streamId == 0) - { // don't send text on channels besides 0 - return QObject::tr("None"); + // Streaming disabled gate. + if (isHttp && !Options::getInstance().streamingEnabled()) + { + destroyStream(streamId); + return QObject::tr("[MISSING] Streaming disabled."); + } + + // "~stop.mp3" is a sentinel meaning "stop the current music." + if (is_stop) + { + destroyStream(streamId); + return streamId == 0 ? QObject::tr("None") : QString(); } - if (error == BASS_ERROR_HANDLE) - { // Cheap hack to see if file missing + // Resolve local path and check existence. + QString resolvedPath = isHttp ? song : ao_app->get_real_path(ao_app->get_music_path(song)); + if (!isHttp && !file_exists(resolvedPath)) + { + destroyStream(streamId); return QObject::tr("[MISSING] %1").arg(p_song_clear); } - if (song.startsWith("http") && streamId == 0) + Stream &s = m_streams[streamId]; + QMediaPlayer *oldPlayer = s.player; + QAudioOutput *oldOutput = s.output; + qint64 oldPositionMs = (oldPlayer && oldPlayer->playbackState() == QMediaPlayer::PlayingState) + ? oldPlayer->position() + : -1; + + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + + // Build the new player. + auto *player = new QMediaPlayer(); + auto *output = new QAudioOutput(); + player->setAudioOutput(output); + + s.player = player; + s.output = output; + s.loop_start_ms = 0; + s.loop_end_ms = 0; + + // Parse sidecar loop metadata for local files. + QString sidecar = resolvedPath + ".txt"; + if (isLooping && !isHttp && file_exists(sidecar)) + { + parseLoopSidecar(streamId, sidecar, resolvedPath); + } + + player->setSource(isHttp ? QUrl(song) : QUrl::fromLocalFile(resolvedPath)); + + // SYNC_POS: seek the new player to the old player's position once it's loaded. + if (oldPositionMs >= 0 && (effectFlags & SYNC_POS)) + { + QObject::connect(player, &QMediaPlayer::mediaStatusChanged, player, + [player, oldPositionMs](QMediaPlayer::MediaStatus status) { + if (status == QMediaPlayer::LoadedMedia || status == QMediaPlayer::BufferedMedia) + { + player->setPosition(oldPositionMs); + } + }); + } + + // Configure looping for the new stream. + if (isLooping) + { + armLoopWatcher(streamId); + } + else + { + player->setLoops(1); + } + + // FADE_IN: ramp volume from 0 to target over FADE_IN_MS. + if (effectFlags & FADE_IN) + { + output->setVolume(0.0f); + auto *anim = new QVariantAnimation(player); + anim->setStartValue(0.0f); + float target = m_muted ? 0.0f : (m_volume[streamId] / 100.0f); + anim->setEndValue(target); + anim->setDuration(FADE_IN_MS); + anim->setEasingCurve(QEasingCurve::OutExpo); + QObject::connect(anim, &QVariantAnimation::valueChanged, output, + [output](const QVariant &v) { output->setVolume(v.toFloat()); }); + anim->start(QAbstractAnimation::DeleteWhenStopped); + } + else + { + applyVolume(streamId); + } + + player->play(); + + // Dispose of the old player. Detach from the Stream struct first so destroyStream + // can't trample our new player if it runs later. + if (oldPlayer) + { + if ((effectFlags & FADE_OUT) && m_volume[streamId] > 0) + { + fadeOutAndDelete(oldPlayer, oldOutput, FADE_OUT_MS); + } + else + { + oldPlayer->stop(); + oldPlayer->deleteLater(); + if (oldOutput) + { + oldOutput->deleteLater(); + } + } + } + + if (isHttp && streamId == 0) { return QObject::tr("[STREAM] %1").arg(p_song_clear); } @@ -193,16 +376,15 @@ QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, return p_song_clear; } - return ""; + return QString(); } void AOMusicPlayer::setMuted(bool enabled) { m_muted = enabled; - // Update all volume based on the mute setting - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) + for (int i = 0; i < STREAM_COUNT; ++i) { - setStreamVolume(m_volume[n_stream], n_stream); + applyVolume(i); } } @@ -213,31 +395,8 @@ void AOMusicPlayer::setStreamVolume(int value, int streamId) qWarning().noquote() << QObject::tr("Invalid stream ID '%2'").arg(streamId); return; } - m_volume[streamId] = value; - // If muted, volume will always be 0 - float volume = (m_volume[streamId] / 100.0f) * !m_muted; - if (streamId < 0) - { - for (int n_stream = 0; n_stream < STREAM_COUNT; ++n_stream) - { - BASS_ChannelSetAttribute(m_stream_list[n_stream], BASS_ATTRIB_VOL, volume); - } - } - else - { - BASS_ChannelSetAttribute(m_stream_list[streamId], BASS_ATTRIB_VOL, volume); - } -} - -void CALLBACK loopProc(HSYNC handle, DWORD channel, DWORD data, void *user) -{ - Q_UNUSED(handle); - Q_UNUSED(data); - QWORD loop_start = *(static_cast(user)); - BASS_ChannelLock(channel, true); - BASS_ChannelSetPosition(channel, loop_start, BASS_POS_BYTE); - BASS_ChannelLock(channel, false); + applyVolume(streamId); } void AOMusicPlayer::setStreamLooping(bool enabled, int streamId) @@ -248,40 +407,23 @@ void AOMusicPlayer::setStreamLooping(bool enabled, int streamId) return; } - if (!enabled) + Stream &s = m_streams[streamId]; + if (!s.player) { - if (BASS_ChannelFlags(m_stream_list[streamId], 0, 0) & BASS_SAMPLE_LOOP) - { - BASS_ChannelFlags(m_stream_list[streamId], 0, - BASS_SAMPLE_LOOP); // remove the LOOP flag - } - BASS_ChannelRemoveSync(m_stream_list[streamId], m_loop_sync[streamId]); - m_loop_sync[streamId] = 0; return; } - BASS_ChannelFlags(m_stream_list[streamId], BASS_SAMPLE_LOOP, - BASS_SAMPLE_LOOP); // set the LOOP flag - if (m_loop_sync[streamId] != 0) - { - BASS_ChannelRemoveSync(m_stream_list[streamId], - m_loop_sync[streamId]); // remove the sync - m_loop_sync[streamId] = 0; - } - - if (m_loop_start[streamId] < m_loop_end[streamId]) - { - // Loop when the endpoint is reached. - m_loop_sync[streamId] = BASS_ChannelSetSync(m_stream_list[streamId], BASS_SYNC_POS | BASS_SYNC_MIXTIME, m_loop_end[streamId], loopProc, &m_loop_start[streamId]); - } - else + if (!enabled) { - // Loop when the end of the file is reached. - m_loop_sync[streamId] = BASS_ChannelSetSync(m_stream_list[streamId], BASS_SYNC_END | BASS_SYNC_MIXTIME, 0, loopProc, &m_loop_start[streamId]); + if (s.loopTimer) + { + s.loopTimer->stop(); + s.loopTimer->deleteLater(); + s.loopTimer = nullptr; + } + s.player->setLoops(1); + return; } -} -bool AOMusicPlayer::ensureValidStreamId(int streamId) -{ - return (streamId >= 0 && streamId < STREAM_COUNT); + armLoopWatcher(streamId); } diff --git a/src/aomusicplayer.h b/src/aomusicplayer.h index 707d64ad3..0d79d4b4e 100644 --- a/src/aomusicplayer.h +++ b/src/aomusicplayer.h @@ -2,7 +2,9 @@ #include "aoapplication.h" -#include +#include +#include +#include class AOMusicPlayer { @@ -21,18 +23,26 @@ class AOMusicPlayer void setStreamVolume(int value, int streamId); void setStreamLooping(bool enabled, int streamId); - QFutureWatcher m_watcher; - private: AOApplication *ao_app; bool m_muted = false; - int m_volume[STREAM_COUNT]{}; - HSTREAM m_stream_list[STREAM_COUNT]{}; - HSYNC m_loop_sync[STREAM_COUNT]{}; - quint32 m_loop_start[STREAM_COUNT]{}; - quint32 m_loop_end[STREAM_COUNT]{}; + struct Stream + { + QMediaPlayer *player = nullptr; + QAudioOutput *output = nullptr; + QTimer *loopTimer = nullptr; + qint64 loop_start_ms = 0; + qint64 loop_end_ms = 0; + }; + Stream m_streams[STREAM_COUNT]; + + void destroyStream(int streamId); + void parseLoopSidecar(int streamId, const QString &dataPath, const QString &mediaPath); + void armLoopWatcher(int streamId); + void applyVolume(int streamId); + void fadeOutAndDelete(QMediaPlayer *player, QAudioOutput *output, int durationMs); bool ensureValidStreamId(int streamId); }; diff --git a/src/courtroom.cpp b/src/courtroom.cpp index 4f1f869c9..24190c2f5 100644 --- a/src/courtroom.cpp +++ b/src/courtroom.cpp @@ -33,7 +33,6 @@ Courtroom::Courtroom(AOApplication *p_ao_app) music_player = new AOMusicPlayer(ao_app); music_player->setMuted(true); - connect(&music_player->m_watcher, &QFutureWatcher::finished, this, &Courtroom::update_ui_music_name, Qt::QueuedConnection); sfx_player = new AOSfxPlayer(ao_app); sfx_player->setMuted(true); @@ -4829,28 +4828,11 @@ void Courtroom::handle_song(QStringList *p_contents) } } - if (channel == 0) + QString result = music_player->playStream(f_song, channel, looping, effect_flags); + if (channel == 0 && !result.isEmpty()) { - // Current song UI only displays the song playing, not other channels. - // Any other music playing is irrelevant. - if (music_player->m_watcher.isRunning()) - { - music_player->m_watcher.cancel(); - } - ui_music_name->setText(tr("[LOADING] %1").arg(f_song_clear)); - } - - music_player->m_watcher.setFuture(QtConcurrent::run([=, this]() -> QString { return music_player->playStream(f_song, channel, looping, effect_flags); })); -} - -void Courtroom::update_ui_music_name() -{ - QString result = music_player->m_watcher.result(); - if (result.isEmpty()) - { - return; + ui_music_name->setText(result); } - ui_music_name->setText(result); } void Courtroom::handle_wtce(QString p_wtce, int variant) diff --git a/src/courtroom.h b/src/courtroom.h index 3b6e2804d..704b8cadf 100644 --- a/src/courtroom.h +++ b/src/courtroom.h @@ -818,8 +818,6 @@ public Q_SLOTS: void on_reload_theme_clicked(); - void update_ui_music_name(); - private Q_SLOTS: void start_chat_ticking(); void play_sfx(); From 70e65b0e35ecc3b9220783a2291f9052ec510a12 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:17:00 +0200 Subject: [PATCH 12/24] Populate audio output picker via QMediaDevices Replaces BASS_GetDeviceInfo enumeration in the options dialog with QMediaDevices::audioOutputs(). Devices are still stored by description string, so existing user prefs migrate transparently. Actual routing of the players through the selected device is wired up in the next commit once AOApplication grows currentAudioDevice(). Co-Authored-By: Claude Opus 4.7 --- src/widgets/aooptionsdialog.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/widgets/aooptionsdialog.cpp b/src/widgets/aooptionsdialog.cpp index d580caaf8..63e1b2c0e 100644 --- a/src/widgets/aooptionsdialog.cpp +++ b/src/widgets/aooptionsdialog.cpp @@ -7,9 +7,9 @@ #include "networkmanager.h" #include "options.h" -#include - +#include #include +#include #include #include #include @@ -31,10 +31,10 @@ void AOOptionsDialog::populateAudioDevices() ui_audio_device_combobox->addItem("default", "default"); } - BASS_DEVICEINFO info; - for (int a = 0; BASS_GetDeviceInfo(a, &info); a++) + const QList devices = QMediaDevices::audioOutputs(); + for (const QAudioDevice &dev : devices) { - ui_audio_device_combobox->addItem(info.name, info.name); + ui_audio_device_combobox->addItem(dev.description(), dev.description()); } } From 70a79a4d26cf0f6185a66e9cf6df4b4979381a68 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:20:03 +0200 Subject: [PATCH 13/24] Delete BASS plumbing; route players through QAudioDevice - AOApplication: drop #include , initBASS, load_bass_plugins, BASSreset, doBASSreset. Add QAudioDevice currentAudioDevice() that resolves Options::audioOutputDevice() against QMediaDevices, falling back to defaultAudioOutput(). - Courtroom: drop ao_app->initBASS() from the constructor. - AOMusicPlayer, AOSfxPlayer, AOBlipPlayer: route through ao_app->currentAudioDevice() so the options dialog picker actually selects the output device. - Lobby about box: update the "BASS audio engine" line to QtMultimedia. After this commit there are no BASS references left in src/. The configure.sh download and the CMake target_link_libraries entries are removed in the next commit. Co-Authored-By: Claude Opus 4.7 --- src/aoapplication.cpp | 69 +++++++------------------------------------ src/aoapplication.h | 10 +++---- src/aoblipplayer.cpp | 2 ++ src/aomusicplayer.cpp | 1 + src/aosfxplayer.cpp | 1 + src/courtroom.cpp | 1 - src/lobby.cpp | 2 +- 7 files changed, 19 insertions(+), 67 deletions(-) diff --git a/src/aoapplication.cpp b/src/aoapplication.cpp index c38eef7e9..476d561f6 100644 --- a/src/aoapplication.cpp +++ b/src/aoapplication.cpp @@ -6,6 +6,8 @@ #include "options.h" #include "widgets/aooptionsdialog.h" +#include + static QtMessageHandler original_message_handler; static AOApplication *message_handler_context; @@ -214,24 +216,6 @@ void AOApplication::call_settings_menu() delete l_dialog; } -// Callback for when BASS device is lost -// Only actually used for music syncs -void CALLBACK AOApplication::BASSreset(HSTREAM handle, DWORD channel, DWORD data, void *user) -{ - Q_UNUSED(handle); - Q_UNUSED(channel); - Q_UNUSED(data); - Q_UNUSED(user); - doBASSreset(); -} - -void AOApplication::doBASSreset() -{ - BASS_Free(); - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); -} - void AOApplication::server_connected() { qInfo() << "Established connection to server."; @@ -242,36 +226,21 @@ void AOApplication::server_connected() courtroom_loaded = false; } -void AOApplication::initBASS() +QAudioDevice AOApplication::currentAudioDevice() const { - BASS_SetConfig(BASS_CONFIG_DEV_DEFAULT, 1); - BASS_Free(); - // Change the default audio output device to be the one the user has given - // in his config.ini file for now. - unsigned int a = 0; - BASS_DEVICEINFO info; - - if (Options::getInstance().audioOutputDevice() == "default") - { - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); - } - else + QString pref = Options::getInstance().audioOutputDevice(); + if (pref != "default") { - for (a = 0; BASS_GetDeviceInfo(a, &info); a++) + const QList devices = QMediaDevices::audioOutputs(); + for (const QAudioDevice &dev : devices) { - if (Options::getInstance().audioOutputDevice() == info.name) + if (dev.description() == pref) { - BASS_SetDevice(a); - BASS_Init(static_cast(a), 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); - qInfo() << info.name << "was set as the default audio output device."; - return; + return dev; } } - BASS_Init(-1, 48000, BASS_DEVICE_LATENCY, nullptr, nullptr); - load_bass_plugins(); } + return QMediaDevices::defaultAudioOutput(); } bool AOApplication::pointExistsOnScreen(QPoint point) @@ -302,21 +271,3 @@ void AOApplication::centerOrMoveWidgetOnPrimaryScreen(QWidget *widget) } } -#if (defined(_WIN32) || defined(_WIN64)) -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("bassopus.dll", 0); -} -#elif defined __APPLE__ -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("libbassopus.dylib", 0); -} -#elif (defined(LINUX) || defined(__linux__)) -void AOApplication::load_bass_plugins() -{ - BASS_PluginLoad("libbassopus.so", 0); -} -#else -#error This operating system is unsupported for BASS plugins. -#endif diff --git a/src/aoapplication.h b/src/aoapplication.h index 92611ec1a..d0fa705f6 100644 --- a/src/aoapplication.h +++ b/src/aoapplication.h @@ -7,8 +7,7 @@ #include "serverdata.h" #include "widgets/aooptionsdialog.h" -#include - +#include #include #include #include @@ -335,10 +334,9 @@ class AOApplication : public QObject bool pointExistsOnScreen(QPoint point); void centerOrMoveWidgetOnPrimaryScreen(QWidget *widget); - void initBASS(); - static void load_bass_plugins(); - static void CALLBACK BASSreset(HSTREAM handle, DWORD channel, DWORD data, void *user); - static void doBASSreset(); + // Resolve the user-selected audio output device. Returns the default + // output if the preference is "default" or no match is found. + QAudioDevice currentAudioDevice() const; QElapsedTimer demo_timer; DemoServer *demo_server = nullptr; diff --git a/src/aoblipplayer.cpp b/src/aoblipplayer.cpp index ed3191faa..3badf0cac 100644 --- a/src/aoblipplayer.cpp +++ b/src/aoblipplayer.cpp @@ -30,8 +30,10 @@ void AOBlipPlayer::setBlip(QString blip) QString path = ao_app->get_sfx_suffix(ao_app->get_sounds_path(blip)); QString playable = resolveToPlayablePath(path); QUrl source = QUrl::fromLocalFile(playable); + QAudioDevice device = ao_app->currentAudioDevice(); for (int i = 0; i < STREAM_COUNT; ++i) { + m_stream[i].setAudioDevice(device); m_stream[i].setSource(source); } updateInternalVolume(); diff --git a/src/aomusicplayer.cpp b/src/aomusicplayer.cpp index 0fa0488f2..96d5b166c 100644 --- a/src/aomusicplayer.cpp +++ b/src/aomusicplayer.cpp @@ -288,6 +288,7 @@ QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, // Build the new player. auto *player = new QMediaPlayer(); auto *output = new QAudioOutput(); + output->setDevice(ao_app->currentAudioDevice()); player->setAudioOutput(output); s.player = player; diff --git a/src/aosfxplayer.cpp b/src/aosfxplayer.cpp index 7a175404b..2f6599d14 100644 --- a/src/aosfxplayer.cpp +++ b/src/aosfxplayer.cpp @@ -36,6 +36,7 @@ void AOSfxPlayer::play(QString path) } QSoundEffect &stream = m_stream[m_current_stream_id]; + stream.setAudioDevice(ao_app->currentAudioDevice()); stream.setLoopCount(1); stream.setSource(QUrl::fromLocalFile(path)); updateInternalVolume(); diff --git a/src/courtroom.cpp b/src/courtroom.cpp index 24190c2f5..bf2ded8e6 100644 --- a/src/courtroom.cpp +++ b/src/courtroom.cpp @@ -16,7 +16,6 @@ Courtroom::Courtroom(AOApplication *p_ao_app) setWindowFlags((this->windowFlags() | Qt::CustomizeWindowHint) & ~Qt::WindowMaximizeButtonHint); setObjectName("courtroom"); - ao_app->initBASS(); keepalive_timer = new QTimer(this); keepalive_timer->start(45000); diff --git a/src/lobby.cpp b/src/lobby.cpp index c019135d6..a482b34e4 100644 --- a/src/lobby.cpp +++ b/src/lobby.cpp @@ -303,7 +303,7 @@ void Lobby::on_about_clicked() "is copyright (c) 2016-2022 Attorney Online developers. Open-source " "licenses apply. All other assets are the property of their " "respective owners." - "

Running on Qt version %2 with the BASS audio engine.
" + "

Running on Qt version %2 with the QtMultimedia audio backend.
" "APNG plugin loaded: %3" "

Built on %4") .arg(ao_app->get_version_string()) From 6f7ac0b9c6d3d92ad323d998d3b7681f383a16db Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:20:56 +0200 Subject: [PATCH 14/24] Drop bass/bassopus from CMakeLists and configure.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final step of the BASS → QtMultimedia migration. Removes the target_link_libraries entries and the configure.sh download functions plus their call sites. lib/bass.h, lib/libbass.dylib, etc. on the contributor's filesystem are gitignored and get cleaned by `./configure.sh clean`. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 2 -- configure.sh | 54 -------------------------------------------------- 2 files changed, 56 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3be6600d9..082d1fc6e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -126,8 +126,6 @@ target_link_libraries(Attorney_Online PRIVATE Qt${QT_VERSION_MAJOR}::WebSockets Qt${QT_VERSION_MAJOR}::UiTools Qt${QT_VERSION_MAJOR}::Multimedia - bass - bassopus ) if(AO_ENABLE_DISCORD_RPC) diff --git a/configure.sh b/configure.sh index 1d95f829a..7488956e3 100755 --- a/configure.sh +++ b/configure.sh @@ -244,58 +244,6 @@ get_zip() { rm -rf "$tmp_zip" } -get_bass() { - echo "Checking for BASS..." - # If lib/bass.h exists, assume that BASS is already present - if [ -f "./lib/bass.h" ]; then - echo "BASS is installed." - return 0 - fi - - echo "Downloading BASS..." - if [[ "$PLATFORM" == "windows" ]]; then - get_zip https://www.un4seen.com/files/bass24.zip \ - c/bass.h:./lib \ - c/x64/bass.lib:./lib \ - x64/bass.dll:./bin - elif [[ "$PLATFORM" == "linux" ]]; then - get_zip https://www.un4seen.com/files/bass24-linux.zip \ - c/bass.h:./lib \ - libs/x86_64/libbass.so:./lib \ - libs/x86_64/libbass.so:./bin - elif [[ "$PLATFORM" == "macos" ]]; then - get_zip https://www.un4seen.com/files/bass24-osx.zip \ - c/bass.h:./lib \ - libbass.dylib:./lib - fi -} - -get_bassopus() { - echo "Checking for BASSOPUS..." - # If lib/bassopus.h exists, assume that BASSOPUS is already present - if [ -f "./lib/bassopus.h" ]; then - echo "BASSOPUS is installed." - return 0 - fi - - echo "Downloading BASSOPUS..." - if [[ "$PLATFORM" == "windows" ]]; then - get_zip https://www.un4seen.com/files/bassopus24.zip \ - c/bassopus.h:./lib \ - c/x64/bassopus.lib:./lib \ - x64/bassopus.dll:./bin - elif [[ "$PLATFORM" == "linux" ]]; then - get_zip https://www.un4seen.com/files/bassopus24-linux.zip \ - c/bassopus.h:./lib \ - libs/x86_64/libbassopus.so:./lib \ - libs/x86_64/libbassopus.so:./bin - elif [[ "$PLATFORM" == "macos" ]]; then - get_zip https://www.un4seen.com/files/bassopus24-osx.zip \ - c/bassopus.h:./lib \ - libbassopus.dylib:./lib - fi -} - get_discordrpc() { echo "Checking for Discord RPC..." # If lib/discord_rpc.h exists, assume that Discord RPC is already present @@ -515,8 +463,6 @@ configure() { mkdir -p ./bin/ # Get the dependencies - get_bass - get_bassopus get_discordrpc get_qtapng get_themes From 9b70bc39f54eed7bc81d3a506b890220b41d48cb Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:47:33 +0200 Subject: [PATCH 15/24] CI: have build.yml call configure.sh instead of duplicating deps work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each platform job now collapses to: checkout → install Qt → run ./configure.sh → deploy + upload. The inline BASS/discord-rpc/QtApng/ themes downloads and the cmake/ninja invocations are gone — configure.sh already does all of that, and on Windows runs windeployqt too. To make configure.sh usable from CI: - Add BUILD_TYPE= CLI override (replaces the hardcoded Debug default). - find_ninja falls back to ninja on PATH when none is bundled with Qt; GitHub runners have ninja from the system rather than under Qt/Tools. The Linux job keeps its patchelf + tar packaging and AppImage build, since those are deployment artifacts that configure.sh isn't in the business of producing. It also stages the qtapng .so into Qt's plugins dir so appimagetool can pick it up. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 163 +++++++----------------------------- configure.sh | 25 ++++-- 2 files changed, 45 insertions(+), 143 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e77c7004..443726e89 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,6 @@ -# This starter workflow is for a CMake project running on multiple platforms. There is a different starter workflow if you just want a single platform. -# See: https://github.com/actions/starter-workflows/blob/main/ci/cmake-single-platform.yml +# Build matrix: each platform job installs Qt, runs ./configure.sh (which +# handles deps, qtapng, themes, cmake+ninja, and windeployqt on Windows), +# then deploys + uploads. name: CI Build on: @@ -28,7 +29,7 @@ jobs: - uses: actions/checkout@master with: submodules: recursive - + - name: Install Qt uses: jurplel/install-qt-action@v4 with: @@ -37,75 +38,20 @@ jobs: arch: win64_mingw cache: true cache-key-prefix: install-qt-action - modules: 'qtimageformats qtwebsockets' + modules: 'qtimageformats qtwebsockets qtmultimedia' - - name: Install Windows Discord RPC - shell: bash - run: | - curl -L https://github.com/discordapp/discord-rpc/releases/download/v3.4.0/discord-rpc-win.zip -o discord_rpc.zip - unzip discord_rpc.zip - cp ./discord-rpc/win64-dynamic/lib/discord-rpc.lib ./lib/ - cp ./discord-rpc/win64-dynamic/bin/discord-rpc.dll ./bin/ - cp ./discord-rpc/win64-dynamic/include/discord*.h ./lib/ - - - name: Install Windows BASS + - name: Configure and build shell: bash run: | - curl http://www.un4seen.com/files/bass24.zip -o bass.zip - unzip -d bass -o bass.zip - cp ./bass/c/bass.h ./lib - cp ./bass/c/x64/bass.lib ./lib/ - cp ./bass/x64/bass.dll ./bin/ - - curl http://www.un4seen.com/files/bassopus24.zip -o bassopus.zip - unzip -d bass -o bassopus.zip - cp ./bass/c/bassopus.h ./lib - cp ./bass/c/x64/bassopus.lib ./lib/ - cp ./bass/x64/bassopus.dll ./bin/ - - - name: Clone Apng plugin - uses: actions/checkout@master - with: - repository: jurplel/QtApng - path: ./qtapng - - - name: Build Apng plugin - run: | - cd ./qtapng - cmake . -G "MinGW Makefiles" - cmake --build . --config Release - mkdir -p ${{ github.workspace }}/bin/imageformats/ - cp plugins/imageformats/qapng.dll ${{ github.workspace }}/bin/imageformats/qapng.dll - - - name: Build - run: | - cmake . -G "MinGW Makefiles" -D CMAKE_BUILD_TYPE=Release - cmake --build . --config Release - - - name: Deploy Windows - working-directory: ${{github.workspace}}/bin/ - shell: bash - run: | - windeployqt --no-quick-import --no-translations --no-compiler-runtime --no-opengl-sw ./Attorney_Online.exe - - - name: Clone Themes - uses: actions/checkout@master - with: - repository: AttorneyOnline/AO2-Themes - path: "bin/base/themes" - - - name: Cleanup Themes Checkout - run: | - rm ./bin/base/themes/.gitignore - rm ./bin/base/themes/.gitattributes - Remove-Item -Path "./bin/base/themes/.git" -Recurse -Force + QT_ROOT="$(cd "$QT_ROOT_DIR/../.." && pwd)" + ./configure.sh QT_ROOT="$QT_ROOT" QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release - name: Upload Artifact uses: actions/upload-artifact@master with: name: Attorney_Online-Windows - path: ${{github.workspace}}/bin - + path: ${{ github.workspace }}/bin + build-linux: needs: formatting-check runs-on: ubuntu-22.04 @@ -124,103 +70,52 @@ jobs: arch: 'gcc_64' cache: true cache-key-prefix: install-qt-action - modules: 'qtimageformats qtwebsockets' - - - name: Install Linux Discord RPC - run: | - curl -L https://github.com/discordapp/discord-rpc/releases/download/v3.4.0/discord-rpc-linux.zip -o discord_rpc.zip - unzip discord_rpc.zip - cp ./discord-rpc/linux-dynamic/lib/libdiscord-rpc.so ./lib/ - cp ./discord-rpc/linux-dynamic/lib/libdiscord-rpc.so ./bin/ - cp ./discord-rpc/linux-dynamic/include/discord*.h ./src/ - - - name: Install Linux BASS - run: | - curl http://www.un4seen.com/files/bass24-linux.zip -o bass.zip - unzip -d bass -o bass.zip - cp ./bass/c/bass.h ./lib - cp ./bass/libs/x86_64/libbass.so ./lib/ - cp ./bass/libs/x86_64/libbass.so ./bin/ - - curl http://www.un4seen.com/files/bassopus24-linux.zip -o bassopus.zip - unzip -d bass -o bassopus.zip - cp ./bass/c/bassopus.h ./lib - cp ./bass/libs/x86_64/libbassopus.so ./lib/ - cp ./bass/libs/x86_64/libbassopus.so ./bin/ - - - name: Clone Apng plugin - uses: actions/checkout@master - with: - repository: jurplel/QtApng - path: ./qtapng - - - name: Build Apng plugin - run: | - cd ./qtapng - cmake . -D CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE="${{ github.workspace }}/bin/imageformats/" - cmake --build . --config Release + modules: 'qtimageformats qtwebsockets qtmultimedia' - # install plugin - cp plugins/imageformats/libqapng.so ${QT_ROOT_DIR}/plugins/imageformats + - name: Install system build tools + run: sudo apt-get update && sudo apt-get install -y ninja-build patchelf libxcb-cursor0 - - name: Build + - name: Configure and build run: | - cmake . - cmake --build . --config Release - - - name: Clone Themes - uses: actions/checkout@master - with: - repository: AttorneyOnline/AO2-Themes - path: "bin/base/themes" + QT_ROOT="$(cd "$QT_ROOT_DIR/../.." && pwd)" + ./configure.sh QT_ROOT="$QT_ROOT" QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release - - name: Cleanup Themes Checkout + - name: Stage APNG plugin for AppImage run: | - rm ./bin/base/themes/.gitignore - rm ./bin/base/themes/.gitattributes - rm -r ./bin/base/themes/.git + mkdir -p "$QT_ROOT_DIR/plugins/imageformats" + cp qtapng/plugins/imageformats/libqapng.so "$QT_ROOT_DIR/plugins/imageformats/" - - name: Deploy Linux + - name: Package dynamic tarball shell: bash run: | - cd ${{ github.workspace }}/bin - mkdir ./imageformats - cp ../qtapng/plugins/imageformats/libqapng.so ./imageformats + cd "${{ github.workspace }}/bin" cp ../data/logo-client.png ./icon.png cp ../README_LINUX.md . cp ../scripts/DYNAMIC_INSTALL.sh ./INSTALL.sh - chmod +x INSTALL.sh - chmod +x Attorney_Online - + chmod +x INSTALL.sh Attorney_Online patchelf --add-rpath . Attorney_Online - cd .. tar --transform='flags=r;s|bin|Attorney Online|' -cvf Attorney_Online-Dynamic.tar bin - - name: Create AppImage + - name: Build AppImage shell: bash run: | - # necessary, apparently - sudo apt install libxcb-cursor0 # from https://github.com/probonopd/go-appimage/blob/master/src/appimagetool/README.md wget -c https://github.com/$(wget -q https://github.com/probonopd/go-appimage/releases/expanded_assets/continuous -O - | grep "appimagetool-.*-x86_64.AppImage" | head -n 1 | cut -d '"' -f 2) mv appimagetool-*-x86_64.AppImage appimagetool chmod +x appimagetool - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/lib/plugins/imageformats - mkdir -p AppDir/usr/share/applications - + mkdir -p AppDir/usr/bin AppDir/usr/lib/plugins/imageformats AppDir/usr/share/applications cp bin/Attorney_Online AppDir/usr/bin - cp bin/lib*.so AppDir/usr/lib + cp bin/lib*.so AppDir/usr/lib 2>/dev/null || true cp scripts/Attorney_Online.desktop AppDir/usr/share/applications cp data/logo-client.png AppDir/Attorney_Online.png GIT_SHORT_SHA="${GITHUB_SHA::8}" - QTDIR=${QT_ROOT_DIR} ./appimagetool deploy AppDir/usr/share/applications/Attorney_Online.desktop + QTDIR="$QT_ROOT_DIR" ./appimagetool deploy AppDir/usr/share/applications/Attorney_Online.desktop ARCH=x86_64 VERSION=${GIT_SHORT_SHA} ./appimagetool AppDir - - name: Deploy AppImage + - name: Package AppImage tarball shell: bash run: | mkdir bin-appimage @@ -229,9 +124,7 @@ jobs: cp README_LINUX.md bin-appimage cp scripts/APPIMAGE_INSTALL.sh bin-appimage/INSTALL.sh cp Attorney_Online-*-x86_64.AppImage bin-appimage - chmod +x bin-appimage/INSTALL.sh - chmod +x bin-appimage/Attorney_Online-*-x86_64.AppImage - + chmod +x bin-appimage/INSTALL.sh bin-appimage/Attorney_Online-*-x86_64.AppImage tar --transform='flags=r;s|bin-appimage|Attorney Online|' -cvf Attorney_Online-AppImage.tar bin-appimage - name: Upload Dynamic Artifact diff --git a/configure.sh b/configure.sh index 7488956e3..8cf328b0d 100755 --- a/configure.sh +++ b/configure.sh @@ -37,6 +37,7 @@ print_help() { echo " clean: Remove all files from lib, bin and tmp" echo " QT_ROOT=path: Specify the root path to where Qt is installed (eg. /c/Qt/)" echo " QT_PATH=path: Specify the exact Qt install to use, bypassing auto-detection (eg. /c/Qt/6.5.3/mingw_64)" + echo " BUILD_TYPE=Debug|Release: CMake build type (default: Debug)" } # Check if a given command returns a non-zero exit code @@ -168,18 +169,25 @@ find_mingw() { } find_ninja() { - # Find a ninja installation bundled with Qt - QT_TOOLS_PATH="${QT_ROOT}/Tools" - - local ninja_path="" - + # Prefer the ninja bundled with Qt, fall back to ninja on PATH. + local bundled="" if [[ "$PLATFORM" == "windows" ]]; then - ninja_path="${QT_TOOLS_PATH}/Ninja/ninja.exe" + bundled="${QT_ROOT}/Tools/Ninja/ninja.exe" else - ninja_path="${QT_TOOLS_PATH}/Ninja/ninja" + bundled="${QT_ROOT}/Tools/Ninja/ninja" + fi + + if [[ -f "$bundled" ]]; then + echo "$bundled" + return 0 + fi + + if command -v ninja >/dev/null 2>&1; then + echo "ninja" + return 0 fi - echo "$ninja_path" + echo "" } get_zip() { @@ -376,6 +384,7 @@ configure() { case "$1" in QT_ROOT=*) QT_ROOT="${1#*=}" ;; QT_PATH=*) QT_PATH="${1#*=}" ;; + BUILD_TYPE=*) BUILD_CONFIG="${1#*=}" ;; *) echo "Unknown argument: $1"; print_help; exit 1 ;; esac shift From 0380367d58ecc6722233640f66cfacff6703caab Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:49:59 +0200 Subject: [PATCH 16/24] Drop QT_ROOT= CLI override; derive it from QT_PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QT_PATH= already says "use exactly this Qt install" — QT_ROOT was redundant once that existed. The script still uses QT_ROOT internally (find_cmake / find_mingw look under ${QT_ROOT}/Tools), but it's now derived from QT_PATH (two dirname levels up) instead of being a second flag the caller has to remember. CI invocations collapse to a single line. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 8 ++------ configure.sh | 30 ++++++++++-------------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 443726e89..3ae071737 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,9 +42,7 @@ jobs: - name: Configure and build shell: bash - run: | - QT_ROOT="$(cd "$QT_ROOT_DIR/../.." && pwd)" - ./configure.sh QT_ROOT="$QT_ROOT" QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release + run: ./configure.sh QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release - name: Upload Artifact uses: actions/upload-artifact@master @@ -76,9 +74,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y ninja-build patchelf libxcb-cursor0 - name: Configure and build - run: | - QT_ROOT="$(cd "$QT_ROOT_DIR/../.." && pwd)" - ./configure.sh QT_ROOT="$QT_ROOT" QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release + run: ./configure.sh QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release - name: Stage APNG plugin for AppImage run: | diff --git a/configure.sh b/configure.sh index 8cf328b0d..87d5da1dd 100755 --- a/configure.sh +++ b/configure.sh @@ -35,8 +35,7 @@ print_help() { echo "Options:" echo " -h, --help: Print this help message" echo " clean: Remove all files from lib, bin and tmp" - echo " QT_ROOT=path: Specify the root path to where Qt is installed (eg. /c/Qt/)" - echo " QT_PATH=path: Specify the exact Qt install to use, bypassing auto-detection (eg. /c/Qt/6.5.3/mingw_64)" + echo " QT_PATH=path: Use this Qt toolchain directly, skipping auto-detection (eg. /c/Qt/6.5.3/mingw_64)" echo " BUILD_TYPE=Debug|Release: CMake build type (default: Debug)" } @@ -378,11 +377,9 @@ configure() { fi # Parse KEY=VALUE overrides - QT_ROOT="" QT_PATH="" while [ "$#" -gt 0 ]; do case "$1" in - QT_ROOT=*) QT_ROOT="${1#*=}" ;; QT_PATH=*) QT_PATH="${1#*=}" ;; BUILD_TYPE=*) BUILD_CONFIG="${1#*=}" ;; *) echo "Unknown argument: $1"; print_help; exit 1 ;; @@ -390,34 +387,27 @@ configure() { shift done - # Resolve QT_ROOT (auto-detect if not overridden). Still needed even when - # QT_PATH is given explicitly — find_cmake / find_mingw / find_ninja use - # ${QT_ROOT}/Tools. - if [ -z "$QT_ROOT" ]; then - QT_ROOT=$(find_qt) - if [ -z "$QT_ROOT" ]; then - echo "Qt not found. Aborting."; exit 1; - fi - fi - if [ ! -d "$QT_ROOT" ]; then - echo "$QT_ROOT is not a directory. Aborting." - exit 1 - fi - echo "Using Qt root: $QT_ROOT" - - # Resolve QT_PATH: explicit override wins, otherwise auto-detect. + # Resolve QT_PATH: explicit override wins, otherwise auto-detect under $HOME/Qt. + # QT_ROOT is the parent of the version dir, derived from QT_PATH. Tools/ lives + # under it (find_cmake / find_mingw / find_ninja look there). if [ -n "$QT_PATH" ]; then if [ ! -d "$QT_PATH" ]; then echo "$QT_PATH is not a directory. Aborting." exit 1 fi + QT_ROOT="$(cd "$QT_PATH/../.." && pwd)" else + QT_ROOT=$(find_qt) + if [ -z "$QT_ROOT" ]; then + echo "Qt not found. Aborting."; exit 1; + fi QT_PATH=$(find_qtpath) if [ -z "$QT_PATH" ] || [ ! -d "$QT_PATH" ]; then echo "No Qt >= ${QT_MIN_VERSION} found under ${QT_ROOT}. Aborting." exit 1 fi fi + echo "Using Qt root: $QT_ROOT" echo "Using Qt installation: $QT_PATH" # Check for cmake, and prefer the one bundled with Qt From ca9fe6d0cd3bf035d9a249326ad54b722586c234 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 12:56:05 +0200 Subject: [PATCH 17/24] Apply clang-format to the migration touchpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatting-only pass over the files touched by the BASS → QtMultimedia migration so the CI clang-format check passes. No behavior change. Co-Authored-By: Claude Opus 4.7 --- src/aoapplication.cpp | 1 - src/aoblipplayer.cpp | 9 ++++---- src/aomusicplayer.cpp | 37 +++++++++++++-------------------- src/widgets/aooptionsdialog.cpp | 2 +- 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/src/aoapplication.cpp b/src/aoapplication.cpp index 476d561f6..5f8917ddd 100644 --- a/src/aoapplication.cpp +++ b/src/aoapplication.cpp @@ -270,4 +270,3 @@ void AOApplication::centerOrMoveWidgetOnPrimaryScreen(QWidget *widget) widget->move(point->x(), point->y()); } } - diff --git a/src/aoblipplayer.cpp b/src/aoblipplayer.cpp index 3badf0cac..8b4bf1112 100644 --- a/src/aoblipplayer.cpp +++ b/src/aoblipplayer.cpp @@ -125,11 +125,10 @@ QString AOBlipPlayer::resolveToPlayablePath(const QString &sourcePath) } }); QObject::connect(&decoder, &QAudioDecoder::finished, &loop, &QEventLoop::quit); - QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, - [&](QAudioDecoder::Error) { - errored = true; - loop.quit(); - }); + QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, [&](QAudioDecoder::Error) { + errored = true; + loop.quit(); + }); decoder.start(); loop.exec(); diff --git a/src/aomusicplayer.cpp b/src/aomusicplayer.cpp index 96d5b166c..98879a9f4 100644 --- a/src/aomusicplayer.cpp +++ b/src/aomusicplayer.cpp @@ -43,11 +43,10 @@ int probeSampleRate(const QString &mediaPath) } }); QObject::connect(&decoder, &QAudioDecoder::finished, &loop, &QEventLoop::quit); - QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, - [&](QAudioDecoder::Error) { - errored = true; - loop.quit(); - }); + QObject::connect(&decoder, qOverload(&QAudioDecoder::error), &loop, [&](QAudioDecoder::Error) { + errored = true; + loop.quit(); + }); decoder.start(); loop.exec(); @@ -120,8 +119,7 @@ void AOMusicPlayer::fadeOutAndDelete(QMediaPlayer *player, QAudioOutput *output, anim->setEndValue(0.0f); anim->setDuration(durationMs); anim->setEasingCurve(QEasingCurve::InExpo); - QObject::connect(anim, &QVariantAnimation::valueChanged, output, - [output](const QVariant &v) { output->setVolume(v.toFloat()); }); + QObject::connect(anim, &QVariantAnimation::valueChanged, output, [output](const QVariant &v) { output->setVolume(v.toFloat()); }); QObject::connect(anim, &QVariantAnimation::finished, player, [player, output]() { player->stop(); player->deleteLater(); @@ -138,7 +136,7 @@ void AOMusicPlayer::parseLoopSidecar(int streamId, const QString &dataPath, cons QStringList lines = ao_app->read_file(dataPath).split("\n"); bool seconds_mode = false; - int sample_rate = 0; // probed lazily; only needed for legacy non-seconds form + int sample_rate = 0; // probed lazily; only needed for legacy non-seconds form const int sample_size = 2; // 16-bit const int num_channels = 2; @@ -171,8 +169,7 @@ void AOMusicPlayer::parseLoopSidecar(int streamId, const QString &dataPath, cons sample_rate = probeSampleRate(mediaPath); if (sample_rate == 0) { - qWarning() << "Failed to probe sample rate for" << mediaPath - << "— legacy byte-form loop points will be ignored."; + qWarning() << "Failed to probe sample rate for" << mediaPath << "— legacy byte-form loop points will be ignored."; continue; } } @@ -274,9 +271,7 @@ QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, Stream &s = m_streams[streamId]; QMediaPlayer *oldPlayer = s.player; QAudioOutput *oldOutput = s.output; - qint64 oldPositionMs = (oldPlayer && oldPlayer->playbackState() == QMediaPlayer::PlayingState) - ? oldPlayer->position() - : -1; + qint64 oldPositionMs = (oldPlayer && oldPlayer->playbackState() == QMediaPlayer::PlayingState) ? oldPlayer->position() : -1; if (s.loopTimer) { @@ -308,13 +303,12 @@ QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, // SYNC_POS: seek the new player to the old player's position once it's loaded. if (oldPositionMs >= 0 && (effectFlags & SYNC_POS)) { - QObject::connect(player, &QMediaPlayer::mediaStatusChanged, player, - [player, oldPositionMs](QMediaPlayer::MediaStatus status) { - if (status == QMediaPlayer::LoadedMedia || status == QMediaPlayer::BufferedMedia) - { - player->setPosition(oldPositionMs); - } - }); + QObject::connect(player, &QMediaPlayer::mediaStatusChanged, player, [player, oldPositionMs](QMediaPlayer::MediaStatus status) { + if (status == QMediaPlayer::LoadedMedia || status == QMediaPlayer::BufferedMedia) + { + player->setPosition(oldPositionMs); + } + }); } // Configure looping for the new stream. @@ -337,8 +331,7 @@ QString AOMusicPlayer::playStream(QString song, int streamId, bool loopEnabled, anim->setEndValue(target); anim->setDuration(FADE_IN_MS); anim->setEasingCurve(QEasingCurve::OutExpo); - QObject::connect(anim, &QVariantAnimation::valueChanged, output, - [output](const QVariant &v) { output->setVolume(v.toFloat()); }); + QObject::connect(anim, &QVariantAnimation::valueChanged, output, [output](const QVariant &v) { output->setVolume(v.toFloat()); }); anim->start(QAbstractAnimation::DeleteWhenStopped); } else diff --git a/src/widgets/aooptionsdialog.cpp b/src/widgets/aooptionsdialog.cpp index 63e1b2c0e..7db9d71b6 100644 --- a/src/widgets/aooptionsdialog.cpp +++ b/src/widgets/aooptionsdialog.cpp @@ -9,9 +9,9 @@ #include #include -#include #include #include +#include #include #include #include From 1f074a695b9e6c8b28371d50436e6303fd712369 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 13:01:32 +0200 Subject: [PATCH 18/24] configure.sh: don't trip set -e when bundled cmake is absent find_cmake and find_qt both used a `return 1` when no path was found, which combined with `set -e` and `VAR=\$(find_cmake)` exited the script before the caller's "fall back to PATH" branch could run. CI hit this on both Windows and Linux because install-qt-action doesn't install the Tools/CMake_64 (Windows) or Tools/CMake (Linux) directories by default. Both helpers now emit empty stdout on miss and always return 0; callers already inspect the string and handle empty as "not found". Also folded each helper's nested check_path closure into a single platform switch + test for clarity. Co-Authored-By: Claude Opus 4.7 --- configure.sh | 54 ++++++++++++---------------------------------------- 1 file changed, 12 insertions(+), 42 deletions(-) diff --git a/configure.sh b/configure.sh index 87d5da1dd..b6b1b0bb6 100755 --- a/configure.sh +++ b/configure.sh @@ -52,30 +52,16 @@ check_command() { } find_qt() { + # Auto-detect the Qt root by checking common install locations. + # Emits the path on stdout, or empty string if nothing was found. local qt_root="" - - # Function to check if a dir exists - check_path() { - if [[ -d "$1" ]]; then - qt_root="$1" - return 0 - else - return 1 - fi - } - - # Check common Qt installation paths on different OSes if [[ "$PLATFORM" == "windows" ]]; then - # Windows paths, maybe check for more in the future - check_path "/c/Qt" - elif [[ "$PLATFORM" == "linux" ]]; then - check_path "$HOME/Qt" - elif [[ "$PLATFORM" == "macos" ]]; then - check_path "$HOME/Qt" + qt_root="/c/Qt" + else + qt_root="$HOME/Qt" fi - # If qt-cmake is found, print the path - if [[ -n "$qt_root" ]]; then + if [[ -d "$qt_root" ]]; then echo "$qt_root" else echo "" @@ -118,37 +104,21 @@ find_qtpath() { } find_cmake() { + # Prefer the cmake bundled with Qt; emits the path on stdout, or empty + # string if none is bundled (the caller falls back to cmake on PATH). local cmake_path="" - - # Function to check if a file exists - check_path() { - if [[ -f "$1" ]]; then - cmake_path="$1" - return 0 - else - return 1 - fi - } - - # See if we can find the cmake bundled with Qt if [[ "$PLATFORM" == "windows" ]]; then - check_path "${QT_ROOT}/Tools/CMake_64/bin/cmake.exe" + cmake_path="${QT_ROOT}/Tools/CMake_64/bin/cmake.exe" elif [[ "$PLATFORM" == "linux" ]]; then - check_path "${QT_ROOT}/Tools/CMake/bin/cmake" + cmake_path="${QT_ROOT}/Tools/CMake/bin/cmake" elif [[ "$PLATFORM" == "macos" ]]; then - check_path "${QT_ROOT}/Tools/CMake/CMake.app/Contents/bin/cmake" - else - echo "Unsupported platform: ${PLATFORM}" - return 1 + cmake_path="${QT_ROOT}/Tools/CMake/CMake.app/Contents/bin/cmake" fi - # If cmake is found, print the path - if [[ -n "$cmake_path" ]]; then + if [[ -f "$cmake_path" ]]; then echo "$cmake_path" - return 0 else echo "" - return 1 fi } From 0b65c381092a3cefc83e4c22e80aef24729600e9 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 13:06:44 +0200 Subject: [PATCH 19/24] CI Windows: install MinGW alongside Qt; tolerate missing Tools dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the Windows job: - build.yml: request tools_mingw1120 via install-qt-action so MinGW 11.2 (the toolchain Qt 6.5.3 was built with) ends up under \${QT_ROOT}/Tools. install-qt-action doesn't install matching MinGW automatically — the previous workflow worked only because it called cmake with the runner's PATH-resident MinGW. configure.sh looks under Tools/ specifically. - configure.sh: guard find_mingw against a missing Tools/ dir (was shelling out to \`find\` which printed "No such file or directory" then tripped set -e), and add a PATH fallback for gcc/g++ on Windows when no MinGW is bundled. Mirrors the same logic find_cmake already had. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 1 + configure.sh | 37 ++++++++++++++++++++----------------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ae071737..87dfc039f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,6 +39,7 @@ jobs: cache: true cache-key-prefix: install-qt-action modules: 'qtimageformats qtwebsockets qtmultimedia' + tools: 'tools_mingw1120' - name: Configure and build shell: bash diff --git a/configure.sh b/configure.sh index b6b1b0bb6..4856243f6 100755 --- a/configure.sh +++ b/configure.sh @@ -123,17 +123,19 @@ find_cmake() { } find_mingw() { - # Find a mingw installation bundled with Qt - - QT_TOOLS_PATH="${QT_ROOT}/Tools" - - mingw_dir=$(find "${QT_TOOLS_PATH}" -maxdepth 1 -type d -name "mingw*" -print0 | xargs -0 ls -td | head -n 1) - - # Find returns . if the directory is not found - if [[ "$mingw_dir" == "." ]]; then - mingw_dir="" + # Find the newest MinGW installation bundled under ${QT_ROOT}/Tools/. + # Emits the path on stdout, or empty if the Tools dir or mingw dir is absent. + local tools_path="${QT_ROOT}/Tools" + if [[ ! -d "$tools_path" ]]; then + echo "" + return 0 fi + local mingw_dir="" + mingw_dir=$(find "$tools_path" -maxdepth 1 -type d -name "mingw*" -print0 \ + | xargs -0 -r ls -td 2>/dev/null \ + | head -n 1) + echo "$mingw_dir" } @@ -394,20 +396,21 @@ configure() { check_command "$CMAKE" --version || { echo "cmake not working. Aborting."; exit 1; } echo "Using cmake: $CMAKE" - # Find the compiler bundled in Qt + # Prefer the MinGW bundled with Qt on Windows; fall back to gcc/g++ on PATH. + # On non-Windows platforms the system compiler is usually safe. CC="" CXX="" - # If we're on Windows, find mingw if [[ "$PLATFORM" == "windows" ]]; then MINGW_PATH=$(find_mingw) - if [ -z "$MINGW_PATH" ]; then - echo "MinGW not found. Aborting." - exit 1 + if [ -n "$MINGW_PATH" ]; then + CC="${MINGW_PATH}/bin/gcc.exe" + CXX="${MINGW_PATH}/bin/g++.exe" + else + echo "No MinGW bundled with Qt found. Trying PATH..." + CC="gcc" + CXX="g++" fi - CC="${MINGW_PATH}/bin/gcc.exe" - CXX="${MINGW_PATH}/bin/g++.exe" else - # On non-Windows platforms, use the system compiler, as it's usually safe CC="gcc" CXX="g++" fi From 1482a31d438d02bab5d0601c8214da0bdc68a8e7 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 13:10:34 +0200 Subject: [PATCH 20/24] CI Windows: use MinGW 13.1 and stage runtime DLLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools_mingw1120 → tools_mingw1310. MinGW 11.2 isn't reliably available via aqt anymore; 13.1 is what install-qt-action ships for Qt 6.5.3 on Windows (confirmed against akashi's working CI config). - Copy libgcc_s_seh-1.dll / libstdc++-6.dll / libwinpthread-1.dll from ${IQTA_TOOLS}/mingw1310_64/bin into ./bin/ after the build. configure.sh runs windeployqt with --no-compiler-runtime (matches the pre-migration flags), so these need to be staged separately for the artifact to run on a machine without MinGW installed. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87dfc039f..6d5504dde 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,15 +36,22 @@ jobs: version: 6.5.3 target: desktop arch: win64_mingw + tools: 'tools_mingw1310' cache: true cache-key-prefix: install-qt-action modules: 'qtimageformats qtwebsockets qtmultimedia' - tools: 'tools_mingw1120' - name: Configure and build shell: bash run: ./configure.sh QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release + - name: Stage MinGW runtime DLLs + shell: pwsh + run: | + Copy-Item "$Env:IQTA_TOOLS\mingw1310_64\bin\libgcc_s_seh-1.dll" bin\ + Copy-Item "$Env:IQTA_TOOLS\mingw1310_64\bin\libstdc++-6.dll" bin\ + Copy-Item "$Env:IQTA_TOOLS\mingw1310_64\bin\libwinpthread-1.dll" bin\ + - name: Upload Artifact uses: actions/upload-artifact@master with: From 2c14c8f5ea54e02d399c7eef8bf9e29c373a5669 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 13:26:02 +0200 Subject: [PATCH 21/24] Add loop-sidecar parser unit tests + run ctest in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bytes-to-ms conversion in AOMusicPlayer's loop-sidecar parsing is the trickiest pure logic in the migration; this lifts it into a standalone helper so it can be exercised in isolation. - src/loopsidecar.{h,cpp}: pure parseLoopSidecarText(text, rate_provider) → LoopPoints {start_ms, end_ms}. The sample-rate provider is invoked lazily, only when the legacy non-seconds form is encountered. AOMusicPlayer::parseLoopSidecar is now a thin wrapper. - test/test_loopsidecar.cpp: 16 QTest cases covering the seconds and legacy forms, loop_length vs loop_end semantics, lazy probe behaviour (called at most once, skipped entirely for seconds form, treated as failure when returning 0 or null), malformed/unknown line handling, whitespace tolerance, and the override / mid-stream toggle semantics. - enable_testing() moved to the top-level CMakeLists so ctest discovers tests from the build root, not just from test/. - build.yml: add a "Run tests" step (ctest --output-on-failure) to both the Windows and Linux jobs. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 7 ++ CMakeLists.txt | 3 + src/aomusicplayer.cpp | 68 +++----------- src/loopsidecar.cpp | 64 ++++++++++++++ src/loopsidecar.h | 23 +++++ test/CMakeLists.txt | 3 +- test/test_loopsidecar.cpp | 171 ++++++++++++++++++++++++++++++++++++ 7 files changed, 279 insertions(+), 60 deletions(-) create mode 100644 src/loopsidecar.cpp create mode 100644 src/loopsidecar.h create mode 100644 test/test_loopsidecar.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d5504dde..71d4f0c90 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,6 +45,10 @@ jobs: shell: bash run: ./configure.sh QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release + - name: Run tests + shell: bash + run: ctest --output-on-failure + - name: Stage MinGW runtime DLLs shell: pwsh run: | @@ -84,6 +88,9 @@ jobs: - name: Configure and build run: ./configure.sh QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release + - name: Run tests + run: ctest --output-on-failure + - name: Stage APNG plugin for AppImage run: | mkdir -p "$QT_ROOT_DIR/plugins/imageformats" diff --git a/CMakeLists.txt b/CMakeLists.txt index 082d1fc6e..f5c9376e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,6 +77,8 @@ qt_add_executable(Attorney_Online src/hardware_functions.h src/lobby.cpp src/lobby.h + src/loopsidecar.cpp + src/loopsidecar.h src/main.cpp src/network/websocketconnection.cpp src/network/websocketconnection.h @@ -134,6 +136,7 @@ if(AO_ENABLE_DISCORD_RPC) endif() if(AO_BUILD_TESTS) + enable_testing() add_subdirectory(test) endif() diff --git a/src/aomusicplayer.cpp b/src/aomusicplayer.cpp index 98879a9f4..5e7f8211b 100644 --- a/src/aomusicplayer.cpp +++ b/src/aomusicplayer.cpp @@ -2,6 +2,7 @@ #include "datatypes.h" #include "file_functions.h" +#include "loopsidecar.h" #include "options.h" #include @@ -131,66 +132,17 @@ void AOMusicPlayer::fadeOutAndDelete(QMediaPlayer *player, QAudioOutput *output, void AOMusicPlayer::parseLoopSidecar(int streamId, const QString &dataPath, const QString &mediaPath) { Stream &s = m_streams[streamId]; - s.loop_start_ms = 0; - s.loop_end_ms = 0; - - QStringList lines = ao_app->read_file(dataPath).split("\n"); - bool seconds_mode = false; - int sample_rate = 0; // probed lazily; only needed for legacy non-seconds form - const int sample_size = 2; // 16-bit - const int num_channels = 2; - - for (const QString &line : lines) - { - QStringList args = line.split("="); - if (args.size() < 2) + QString text = ao_app->read_file(dataPath); + LoopPoints lp = parseLoopSidecarText(text, [&]() { + int rate = probeSampleRate(mediaPath); + if (rate == 0) { - continue; + qWarning() << "Failed to probe sample rate for" << mediaPath << "— legacy byte-form loop points will be ignored."; } - QString arg = args[0].trimmed(); - QString val = args[1].trimmed(); - - if (arg == "seconds") - { - seconds_mode = (val == "true"); - continue; - } - - qint64 ms = 0; - if (seconds_mode) - { - ms = static_cast(val.toDouble() * 1000.0); - } - else - { - // Legacy form: value is a sample count, converted to ms via probed sample rate. - if (sample_rate == 0) - { - sample_rate = probeSampleRate(mediaPath); - if (sample_rate == 0) - { - qWarning() << "Failed to probe sample rate for" << mediaPath << "— legacy byte-form loop points will be ignored."; - continue; - } - } - quint64 bytes = static_cast(val.toUInt()) * sample_size * num_channels; - quint64 frame_bytes = static_cast(sample_rate) * sample_size * num_channels; - ms = static_cast(bytes * 1000 / frame_bytes); - } - - if (arg == "loop_start") - { - s.loop_start_ms = ms; - } - else if (arg == "loop_length") - { - s.loop_end_ms = s.loop_start_ms + ms; - } - else if (arg == "loop_end") - { - s.loop_end_ms = ms; - } - } + return rate; + }); + s.loop_start_ms = lp.start_ms; + s.loop_end_ms = lp.end_ms; } void AOMusicPlayer::armLoopWatcher(int streamId) diff --git a/src/loopsidecar.cpp b/src/loopsidecar.cpp new file mode 100644 index 000000000..1ae4fd11c --- /dev/null +++ b/src/loopsidecar.cpp @@ -0,0 +1,64 @@ +#include "loopsidecar.h" + +#include + +LoopPoints parseLoopSidecarText(const QString &sidecar_text, const std::function &sample_rate_provider) +{ + LoopPoints out; + bool seconds_mode = false; + int sample_rate = 0; + constexpr int sample_size = 2; // 16-bit + constexpr int num_channels = 2; + + const QStringList lines = sidecar_text.split("\n"); + for (const QString &line : lines) + { + QStringList args = line.split("="); + if (args.size() < 2) + { + continue; + } + QString arg = args[0].trimmed(); + QString val = args[1].trimmed(); + + if (arg == "seconds") + { + seconds_mode = (val == "true"); + continue; + } + + qint64 ms = 0; + if (seconds_mode) + { + ms = static_cast(val.toDouble() * 1000.0); + } + else + { + if (sample_rate == 0) + { + sample_rate = sample_rate_provider ? sample_rate_provider() : 0; + if (sample_rate == 0) + { + continue; + } + } + quint64 bytes = static_cast(val.toUInt()) * sample_size * num_channels; + quint64 frame_bytes = static_cast(sample_rate) * sample_size * num_channels; + ms = static_cast(bytes * 1000 / frame_bytes); + } + + if (arg == "loop_start") + { + out.start_ms = ms; + } + else if (arg == "loop_length") + { + out.end_ms = out.start_ms + ms; + } + else if (arg == "loop_end") + { + out.end_ms = ms; + } + } + return out; +} diff --git a/src/loopsidecar.h b/src/loopsidecar.h new file mode 100644 index 000000000..21cd4cda4 --- /dev/null +++ b/src/loopsidecar.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include + +struct LoopPoints +{ + qint64 start_ms = 0; + qint64 end_ms = 0; +}; + +// Parse loop-point metadata from an AO music sidecar (the `.txt` that lives +// next to a music file). Recognises `seconds=(true|false)`, `loop_start=N`, +// `loop_end=N`, `loop_length=N`. +// +// In the legacy form (`seconds=false` or absent) N is a sample count and +// must be divided by the file's sample rate to get a ms timestamp. +// `sample_rate_provider` is invoked lazily — at most once, only if the +// sidecar actually contains a non-seconds entry — and should return the +// file's sample rate, or 0 to signal probe failure. When 0, legacy entries +// are silently ignored. +LoopPoints parseLoopSidecarText(const QString &sidecar_text, const std::function &sample_rate_provider); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c78740c1c..65a676ea1 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -4,8 +4,6 @@ find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Test REQUIRED) set(CMAKE_INCLUDE_CURRENT_DIR ON) -enable_testing(true) - set(SKIP_AUTOMOC ON) function(ao_declare_test test_id) @@ -19,3 +17,4 @@ function(ao_declare_test test_id) endfunction() ao_declare_test(test_aopacket test_aopacket.cpp ../src/aopacket.cpp) +ao_declare_test(test_loopsidecar test_loopsidecar.cpp ../src/loopsidecar.cpp) diff --git a/test/test_loopsidecar.cpp b/test/test_loopsidecar.cpp new file mode 100644 index 000000000..448d1c5ad --- /dev/null +++ b/test/test_loopsidecar.cpp @@ -0,0 +1,171 @@ +#include "loopsidecar.h" + +#include + +class test_LoopSidecar : public QObject +{ + Q_OBJECT + +private: + // 44100 Hz, 16-bit stereo: 1 second = 44100 samples = 176400 bytes. + // The parser's legacy form takes sample counts, so a value of 44100 → 1000 ms. + static constexpr int SAMPLE_RATE = 44100; + + static std::function fixedRate(int rate) + { + return [rate]() { + return rate; + }; + } + +private Q_SLOTS: + void emptyInput() + { + LoopPoints lp = parseLoopSidecarText("", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void onlyWhitespace() + { + LoopPoints lp = parseLoopSidecarText("\n\n \n", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void secondsFormStartEnd() + { + LoopPoints lp = parseLoopSidecarText("seconds=true\nloop_start=2.5\nloop_end=10", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void secondsFormStartLength() + { + LoopPoints lp = parseLoopSidecarText("seconds=true\nloop_start=2.5\nloop_length=7.5", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void legacyFormStartEnd() + { + // 44100 samples = 1 second at 44.1 kHz; 88200 samples = 2 seconds. + LoopPoints lp = parseLoopSidecarText("loop_start=44100\nloop_end=88200", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + } + + void legacyFormStartLength() + { + LoopPoints lp = parseLoopSidecarText("loop_start=22050\nloop_length=22050", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(500)); + QCOMPARE(lp.end_ms, qint64(1000)); + } + + void explicitSecondsFalseIsLegacy() + { + LoopPoints lp = parseLoopSidecarText("seconds=false\nloop_start=44100\nloop_end=88200", fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + } + + void secondsFormSkipsProbe() + { + int probeCalls = 0; + auto provider = [&]() { + ++probeCalls; + return SAMPLE_RATE; + }; + LoopPoints lp = parseLoopSidecarText("seconds=true\nloop_start=1\nloop_end=2", provider); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(2000)); + QCOMPARE(probeCalls, 0); + } + + void legacyFormProbesAtMostOnce() + { + int probeCalls = 0; + auto provider = [&]() { + ++probeCalls; + return SAMPLE_RATE; + }; + LoopPoints lp = parseLoopSidecarText("loop_start=44100\nloop_length=44100\nloop_end=132300", provider); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(3000)); // loop_end overrides the loop_length computation + QCOMPARE(probeCalls, 1); + } + + void probeFailureSilencesLegacyEntries() + { + LoopPoints lp = parseLoopSidecarText("loop_start=44100\nloop_end=88200", fixedRate(0)); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void nullProviderTreatedAsFailure() + { + LoopPoints lp = parseLoopSidecarText("loop_start=44100\nloop_end=88200", nullptr); + QCOMPARE(lp.start_ms, qint64(0)); + QCOMPARE(lp.end_ms, qint64(0)); + } + + void malformedLinesSkipped() + { + QString input = "this is not a kv line\n" + "seconds=true\n" + "loop_start=2.5\n" + "garbage_without_equals\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void unknownKeysIgnored() + { + QString input = "seconds=true\n" + "loop_start=2.5\n" + "something_else=42\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void whitespaceAroundKvIsTrimmed() + { + QString input = " seconds = true \n" + " loop_start = 2.5\n" + "loop_end= 10 \n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(2500)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void lastLoopStartWins() + { + QString input = "seconds=true\n" + "loop_start=1\n" + "loop_start=5\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(5000)); + QCOMPARE(lp.end_ms, qint64(10000)); + } + + void secondsToggleMidStreamApplies() + { + // Mode change mid-file: loop_start uses legacy (sample count at 44.1k), + // then `seconds=true` flips the mode, and loop_end uses seconds. + QString input = "loop_start=44100\n" + "seconds=true\n" + "loop_end=10\n"; + LoopPoints lp = parseLoopSidecarText(input, fixedRate(SAMPLE_RATE)); + QCOMPARE(lp.start_ms, qint64(1000)); + QCOMPARE(lp.end_ms, qint64(10000)); + } +}; + +#include "test/test_loopsidecar.moc" + +QTEST_APPLESS_MAIN(test_LoopSidecar) From 70d2af0be3f2d07d5fa7b71c2e65aba556e23727 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 13:33:13 +0200 Subject: [PATCH 22/24] CI Linux: run tests under the offscreen QPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_aopacket uses QTEST_MAIN, which builds a QGuiApplication and needs a platform plugin. The Linux runner is headless — xcb can't connect to a display and the test aborts before its first assertion. Force the offscreen QPA for the ctest step so QGuiApplication initialises against a memory-backed surface. test_loopsidecar (QTEST_APPLESS_MAIN) was unaffected. Windows runners have a real display so no equivalent fix is needed there. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 71d4f0c90..2e6d0bccd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,6 +89,8 @@ jobs: run: ./configure.sh QT_PATH="$QT_ROOT_DIR" BUILD_TYPE=Release - name: Run tests + env: + QT_QPA_PLATFORM: offscreen run: ctest --output-on-failure - name: Stage APNG plugin for AppImage From d0ef7f4fd81fa3dfcedea920d1b4265528b3990d Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 13:43:03 +0200 Subject: [PATCH 23/24] configure.sh: drive the build via cmake --build --parallel Switches both the main build and the qtapng sub-build from invoking ninja directly to \`cmake --build . --parallel\`. cmake's --parallel defers to the native build tool's default job count when no number is given (Ninja: nproc+2, MinGW Makefiles: nproc), so this gives explicit parallelism without coupling the script to a specific generator. Matches the idiom akashi uses; harmless on platforms where ninja was already saturating cores. Co-Authored-By: Claude Opus 4.7 --- configure.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/configure.sh b/configure.sh index 4856243f6..7018b0ecc 100755 --- a/configure.sh +++ b/configure.sh @@ -297,7 +297,7 @@ get_qtapng() { -DCMAKE_C_COMPILER="$CC" \ -DCMAKE_CXX_COMPILER="$CXX" - $NINJA + "$CMAKE" --build . --parallel cd "${SCRIPT_DIR}" @@ -458,7 +458,7 @@ $CMAKE . \ ${EXTRA_CMAKE_FLAGS}" $FULL_CMAKE_CMD - $NINJA + "$CMAKE" --build . --parallel if [[ "$PLATFORM" == "windows" ]]; then echo "Fixing dependencies..." From 1afd16d95fda02d0728f39c724e95ca9fa81df54 Mon Sep 17 00:00:00 2001 From: David Skoland Date: Tue, 23 Jun 2026 13:54:10 +0200 Subject: [PATCH 24/24] Revert "configure.sh: drive the build via cmake --build --parallel" This reverts commit d0ef7f4fd81fa3dfcedea920d1b4265528b3990d. --- configure.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/configure.sh b/configure.sh index 7018b0ecc..4856243f6 100755 --- a/configure.sh +++ b/configure.sh @@ -297,7 +297,7 @@ get_qtapng() { -DCMAKE_C_COMPILER="$CC" \ -DCMAKE_CXX_COMPILER="$CXX" - "$CMAKE" --build . --parallel + $NINJA cd "${SCRIPT_DIR}" @@ -458,7 +458,7 @@ $CMAKE . \ ${EXTRA_CMAKE_FLAGS}" $FULL_CMAKE_CMD - "$CMAKE" --build . --parallel + $NINJA if [[ "$PLATFORM" == "windows" ]]; then echo "Fixing dependencies..."