diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-12 07:08:09 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-12 07:08:09 -0500 |
| commit | 755698f1adceef55f52c8e44c9746537885c9bf7 (patch) | |
| tree | 23d1a9fea936805708eeccf2ac2684620565d328 | |
| parent | 019870ffd8308d94289d72d45abeb3db39ccde92 (diff) | |
Replace Python plugins with native C++ .so, remove bundled Python (~95MB)
- Add native plugins: form43_checker, script_extender_checker, preview_dds,
basic_games (75 game defs via IPluginProxy pattern with Steam/GOG detection)
- Remove portable Python from Dockerfile and build-inner.sh
- Add Python settings tab with venv support (system python3 + PyQt6)
- Skip loading plugin_python.so when Python disabled (default: off)
- Update pythonrunner.cpp to use venv at ~/.local/share/fluorine/python-venv/
- Preserve real file permissions in FUSE VFS (fixes native executable +x bits)
- Add FUSE_SET_ATTR_MODE handler so chmod works through VFS
- Fix desktop shortcut Exec= to use fluorine-manager launcher, not bare binary
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
41 files changed, 4640 insertions, 267 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 38b5ec3..3080998 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,6 +225,10 @@ add_subdirectory(libs/preview_bsa) add_subdirectory(libs/bsa_extractor) add_subdirectory(libs/bsapacker) add_subdirectory(libs/diagnose_basic) +add_subdirectory(libs/form43_checker_native) +add_subdirectory(libs/script_extender_checker_native) +add_subdirectory(libs/preview_dds_native) +add_subdirectory(libs/basic_games_native) add_subdirectory(libs/check_fnis) add_subdirectory(libs/tool_inibakery) add_subdirectory(libs/tool_inieditor) diff --git a/docker/Dockerfile b/docker/Dockerfile index 68c7570..a8798dc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -49,15 +49,6 @@ ENV PATH="/opt/build-python/bin:${PATH}" RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ sh -s -- -y --default-toolchain stable --profile minimal -# ── Portable Python runtime (for bundling, not compilation) ── -ARG PORTABLE_PYTHON_VERSION=3.13.9 -RUN curl -L --retry 3 -o /tmp/portable-python.zip \ - "https://github.com/bjia56/portable-python/releases/download/cpython-v${PORTABLE_PYTHON_VERSION}-build.0/python-headless-${PORTABLE_PYTHON_VERSION}-linux-x86_64.zip" && \ - mkdir -p /tmp/pp-extract && \ - unzip -q /tmp/portable-python.zip -d /tmp/pp-extract && \ - mv /tmp/pp-extract/python-headless-*/ /opt/portable-python && \ - rm -rf /tmp/portable-python.zip /tmp/pp-extract - # ── Build and install libloot ── ARG LIBLOOT_REF=master RUN git clone --depth 1 --branch ${LIBLOOT_REF} \ diff --git a/docker/build-inner.sh b/docker/build-inner.sh index 3ced87f..0bcb8b0 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -36,8 +36,6 @@ if [ ! -f "${MODORG_BIN}" ]; then fi RUNDIR="build/src/src" -PY_MM="$("${BUILD_PY}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - # ── Output layout (staging area — installed to ~/.local/share/fluorine by build-native.sh) ── OUT_DIR="/src/build/staging" rm -rf "${OUT_DIR}" @@ -69,23 +67,21 @@ find build/libs -type f \( \ -name "libinibakery.so" -o \ -name "libbsa_extractor.so" -o \ -name "libbsa_packer.so" -o \ - -name "libproxy.so" \ + -name "libproxy.so" -o \ + -name "libform43_checker_native.so" -o \ + -name "libscript_extender_checker_native.so" -o \ + -name "libpreview_dds_native.so" -o \ + -name "libbasic_games_native.so" \ \) -exec cp -f {} "${OUT_DIR}/plugins/" \; -# Python plugin payload. -for f in libplugin_python.so lzokay.py winreg.py pyCfg.py \ - DDSPreview.py Form43Checker.py ScriptExtenderPluginChecker.py; do +# Python plugin loader (small — kept for optional Python support). +[ -f "build/src/src/plugins/libplugin_python.so" ] && cp -f "build/src/src/plugins/libplugin_python.so" "${OUT_DIR}/plugins/" +# Python helper shims (needed by Python plugins when Python is enabled). +for f in lzokay.py winreg.py pyCfg.py; do [ -f "build/src/src/plugins/${f}" ] && cp -f "build/src/src/plugins/${f}" "${OUT_DIR}/plugins/" done -for d in basic_games data libs dlls; do - [ -d "build/src/src/plugins/${d}" ] && cp -a "build/src/src/plugins/${d}" "${OUT_DIR}/plugins/" -done -rm -f "${OUT_DIR}/plugins/FNIS"*.py - -# Source-tree Python plugins (OMOD installer, etc.). -for f in /src/src/plugins/*.py; do - [ -f "${f}" ] && cp -f "${f}" "${OUT_DIR}/plugins/" -done +# data/ dir (DDS headers etc., used by native plugins too). +[ -d "build/src/src/plugins/data" ] && cp -a "build/src/src/plugins/data" "${OUT_DIR}/plugins/" # ── Stylesheets (themes) ── if [ -d "build/src/src/stylesheets" ]; then @@ -125,8 +121,7 @@ echo "Bundling shared library dependencies..." SKIP_PATTERN="linux-vdso|ld-linux|libc\.so|libm\.so|libdl\.so|librt\.so|libpthread|libresolv|libnss|libgcc_s|libstdc\+\+" # GPU/graphics drivers must be host-provided SKIP_PATTERN="${SKIP_PATTERN}|libGL\.so|libEGL|libGLX|libGLdispatch|libdrm|libvulkan|libX11|libxcb|libwayland-client|libwayland-server|libwayland-cursor|libwayland-egl|libxkbcommon" -# libpython is shipped by the portable Python runtime in python/lib/ — do NOT -# copy the container's version into lib/ or it will shadow the portable one. +# libpython — user provides via system Python; do not bundle. SKIP_PATTERN="${SKIP_PATTERN}|libpython" collect_deps() { @@ -142,19 +137,6 @@ find "${OUT_DIR}/plugins" -name "*.so" -exec sh -c 'ldd "$1" 2>/dev/null | grep find "${OUT_DIR}/lib" -name "*.so*" -exec sh -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/"' _ {} \; >> "${ALL_DEPS}" # lootcli [ -f "${OUT_DIR}/lootcli" ] && collect_deps "${OUT_DIR}/lootcli" >> "${ALL_DEPS}" -# PyQt6 extension modules — these link against Qt6 libs that MO2 binaries don't -# directly depend on (e.g. libQt6OpenGLWidgets, libQt6PrintSupport). Without -# bundling these, PyQt6 falls back to host Qt which may be a different version. -# Scan from the system dist-packages (portable Python isn't copied yet). -for pyqt_search in /usr/lib/python3/dist-packages/PyQt6 \ - "/usr/lib/python${PY_MM}/dist-packages/PyQt6" \ - "/usr/local/lib/python${PY_MM}/dist-packages/PyQt6"; do - if [ -d "${pyqt_search}" ]; then - find "${pyqt_search}" -name "*.so" -exec sh -c 'ldd "$1" 2>/dev/null | grep "=>" | awk "{print \$3}" | grep "^/"' _ {} \; >> "${ALL_DEPS}" - break - fi -done - sort -u "${ALL_DEPS}" | while read -r dep; do dep_name="$(basename "${dep}")" # Skip system libs @@ -168,8 +150,6 @@ sort -u "${ALL_DEPS}" | while read -r dep; do cp -Lf "${dep}" "${OUT_DIR}/lib/" 2>/dev/null || true done rm -f "${ALL_DEPS}" -# Remove any libpython that leaked into lib/ — must come from python/lib/ only. -rm -f "${OUT_DIR}/lib"/libpython*.so* 2>/dev/null || true echo "Dependencies bundled." # ── Qt6 platform plugins ── @@ -209,77 +189,11 @@ if [ -f /usr/local/lib/libloot.so.0 ]; then ln -sf libloot.so.0 "${OUT_DIR}/lib/libloot.so" fi -# ── Portable Python runtime ── -PORTABLE_PY="/opt/portable-python" -if [ -d "${PORTABLE_PY}" ]; then - echo "Bundling portable Python from ${PORTABLE_PY}..." - cp -a "${PORTABLE_PY}" "${OUT_DIR}/python" +# ── No portable Python runtime ── +# Python plugins are optional and use the system Python + venv when enabled. +# Native C++ plugins replace DDSPreview, Form43Checker, ScriptExtenderChecker, basic_games. - # Trim unnecessary files from portable Python. - PP_STDLIB="${OUT_DIR}/python/lib/python${PY_MM}" - rm -rf "${PP_STDLIB}/test" \ - "${PP_STDLIB}/unittest/test" \ - "${PP_STDLIB}/idlelib" \ - "${PP_STDLIB}/tkinter" \ - "${PP_STDLIB}/turtledemo" \ - "${PP_STDLIB}/__pycache__" \ - "${OUT_DIR}/python/include" \ - "${OUT_DIR}/python/share" \ - 2>/dev/null || true - find "${OUT_DIR}/python" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true - find "${OUT_DIR}/python" -name "*.pyc" -delete 2>/dev/null || true - - # The portable Python's libpython has SONAME "libpython3.13.so", but - # pybind11::embed links librunner.so against the build-venv's libpython - # which may have SONAME "libpython3.13.so.1.0". A SONAME mismatch causes - # two copies of libpython to be loaded at runtime, making - # Py_IsInitialized() return false after Py_InitializeFromConfig() succeeds. - # - # Fix: patch the portable Python's SONAME to include the .1.0 suffix, - # matching what the linker recorded in librunner.so's DT_NEEDED. - PP_LIBPY="${OUT_DIR}/python/lib/libpython${PY_MM}.so" - if [ -f "${PP_LIBPY}" ]; then - CURRENT_SONAME="$(readelf -d "${PP_LIBPY}" 2>/dev/null | grep SONAME | sed 's/.*\[//' | sed 's/\]//')" - echo "Portable Python SONAME: ${CURRENT_SONAME}" - if [ "${CURRENT_SONAME}" = "libpython${PY_MM}.so" ]; then - echo "Patching SONAME to libpython${PY_MM}.so.1.0 ..." - patchelf --set-soname "libpython${PY_MM}.so.1.0" "${PP_LIBPY}" - # Rename the file to match and create backwards symlink. - mv "${PP_LIBPY}" "${PP_LIBPY}.1.0" - ln -sf "libpython${PY_MM}.so.1.0" "${PP_LIBPY}" - fi - # Ensure the .1.0 name exists (either as the real file or a symlink). - if [ ! -e "${PP_LIBPY}.1.0" ]; then - ln -sf "libpython${PY_MM}.so" "${PP_LIBPY}.1.0" - fi - fi -else - echo "ERROR: Portable Python not found at ${PORTABLE_PY}" - exit 1 -fi - -# Bundle PyQt6 from system into portable Python's site-packages. -PYSITE="${OUT_DIR}/python/lib/python${PY_MM}/site-packages" -mkdir -p "${PYSITE}" -for search_dir in /usr/lib/python3/dist-packages \ - "/usr/lib/python${PY_MM}/dist-packages" \ - "/usr/local/lib/python${PY_MM}/dist-packages"; do - if [ -d "${search_dir}/PyQt6" ]; then - echo "Bundling PyQt6 from ${search_dir}..." - cp -a "${search_dir}/PyQt6" "${PYSITE}/" - [ -d "${search_dir}/PyQt6_sip" ] && cp -a "${search_dir}/PyQt6_sip" "${PYSITE}/" - [ -d "${search_dir}/sip" ] && cp -a "${search_dir}/sip" "${PYSITE}/" - break - fi -done - -# Install Python packages into portable runtime via uv. -uv pip install --python "${OUT_DIR}/python/bin/python3" psutil vdf - -# Build-tree Python plugin payload. -[ -d build/src/src/python ] && cp -a build/src/src/python/. "${OUT_DIR}/python/" - -# ── Strip all MO2 binaries (not portable Python) ── +# ── Strip all MO2 binaries ── echo "Stripping MO2 binaries..." strip --strip-unneeded "${OUT_DIR}/ModOrganizer-core" 2>/dev/null || true find "${OUT_DIR}/plugins" -name "*.so" -exec strip --strip-unneeded {} \; 2>/dev/null || true @@ -293,21 +207,10 @@ done # Use --force-rpath to set DT_RPATH (not DT_RUNPATH) for reliable # library resolution regardless of LD_LIBRARY_PATH. echo "Patching RPATH..." -patchelf --force-rpath --set-rpath '$ORIGIN/lib:$ORIGIN/python/lib' "${OUT_DIR}/ModOrganizer-core" +patchelf --force-rpath --set-rpath '$ORIGIN/lib' "${OUT_DIR}/ModOrganizer-core" [ -f "${OUT_DIR}/lootcli" ] && patchelf --force-rpath --set-rpath '$ORIGIN/lib' "${OUT_DIR}/lootcli" -find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN/../lib:$ORIGIN/../python/lib' {} \; 2>/dev/null || true -# Libraries in lib/ (e.g. librunner.so) need to find sibling libs and python/lib. -find "${OUT_DIR}/lib" \( -name "*.so" -o -name "*.so.*" \) -exec patchelf --force-rpath --set-rpath '$ORIGIN:$ORIGIN/../python/lib' {} \; 2>/dev/null || true - -# ── Validate embedded Python runtime ── -if ! PYTHONHOME="${OUT_DIR}/python" \ - PYTHONPATH="${OUT_DIR}/python/lib/python${PY_MM}:${PYSITE}" \ - LD_LIBRARY_PATH="${OUT_DIR}/lib:${OUT_DIR}/python/lib:${LD_LIBRARY_PATH:-}" \ - "${OUT_DIR}/python/bin/python3" -c \ - "import zlib; import runpy; import zipimport; print('python embed check ok')"; then - echo "ERROR: Embedded Python runtime check failed." - exit 1 -fi +find "${OUT_DIR}/plugins" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN/../lib' {} \; 2>/dev/null || true +find "${OUT_DIR}/lib" \( -name "*.so" -o -name "*.so.*" \) -exec patchelf --force-rpath --set-rpath '$ORIGIN' {} \; 2>/dev/null || true # ── Launcher script ── cat > "${OUT_DIR}/fluorine-manager" <<'LAUNCH' @@ -350,26 +253,20 @@ fi # Run from the synced location. RUN="${BIN_DST}" -PYTHON_DIR="${RUN}/python" export PATH="${RUN}:${PATH}" # NOTE: Do NOT set LD_LIBRARY_PATH here. The binary uses DT_RPATH -# ($ORIGIN/lib:$ORIGIN/python/lib) to find its libraries. +# ($ORIGIN/lib) to find its libraries. export MO2_BASE_DIR="${RUN}" export MO2_PLUGINS_DIR="${RUN}/plugins" export MO2_DLLS_DIR="${RUN}/dlls" -export MO2_PYTHON_DIR="${PYTHON_DIR}" -# PYTHONHOME is set only for the MO2 process (not exported to children like -# Proton which has its own Python). MO2_PYTHON_DIR lets the -# binary reconstruct it internally. -MO2_PYTHONHOME="${PYTHON_DIR}" -unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME +unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME MO2_PYTHON_DIR # Use bundled Qt6 plugins. export QT_PLUGIN_PATH="${RUN}/qt6plugins" cd "${RUN}" -exec env PYTHONHOME="${MO2_PYTHONHOME}" "${RUN}/ModOrganizer-core" "$@" +exec "${RUN}/ModOrganizer-core" "$@" LAUNCH chmod +x "${OUT_DIR}/fluorine-manager" @@ -543,14 +440,6 @@ build_appimage() { cp -a "${APPDIR}/usr/bin/qt6plugins"/. "${APPDIR}/usr/plugins/" fi - PP_APPDIR_LIB="${APPDIR}/usr/bin/python/lib" - for pp_lib in "${PP_APPDIR_LIB}"/libpython*.so*; do - [ -e "${pp_lib}" ] || continue - pp_name="$(basename "${pp_lib}")" - rm -f "${APPDIR}/usr/lib/${pp_name}" - ln -sf ../bin/python/lib/"${pp_name}" "${APPDIR}/usr/lib/${pp_name}" - done - cp -f "${OUT_DIR}/com.fluorine.manager.desktop" "${APPDIR}/usr/share/applications/" cp -f "${OUT_DIR}/com.fluorine.manager.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/" cp -f "${OUT_DIR}/com.fluorine.manager.metainfo.xml" "${APPDIR}/usr/share/metainfo/" @@ -565,10 +454,10 @@ build_appimage() { cp -f "/usr/share/icons/default/index.theme" "${APPDIR}/usr/share/icons/default/" fi - patchelf --force-rpath --set-rpath '$ORIGIN/../lib:$ORIGIN/python/lib' "${APPDIR}/usr/bin/ModOrganizer-core" + patchelf --force-rpath --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/ModOrganizer-core" [ -f "${APPDIR}/usr/bin/lootcli" ] && patchelf --force-rpath --set-rpath '$ORIGIN/../lib' "${APPDIR}/usr/bin/lootcli" find "${APPDIR}/usr/bin/plugins" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN/../../lib' {} \; 2>/dev/null || true - find "${APPDIR}/usr/lib" -name "*.so" -not -name "libpython*" -exec patchelf --force-rpath --set-rpath '$ORIGIN' {} \; 2>/dev/null || true + find "${APPDIR}/usr/lib" -name "*.so" -exec patchelf --force-rpath --set-rpath '$ORIGIN' {} \; 2>/dev/null || true cat > "${APPDIR}/AppRun" <<'APPRUN' #!/usr/bin/env bash @@ -578,38 +467,19 @@ HERE="$(cd "$(dirname "$SELF")" && pwd)" BIN="${HERE}/usr/bin" APPIMAGE_DIR="$(dirname "$(readlink -f "${APPIMAGE:-$0}")")" -FLUORINE_DATA="${HOME}/.local/share/fluorine" -PYTHON_DST="${FLUORINE_DATA}/python" -PYTHON_SRC="${BIN}/python" - -if [ -d "${PYTHON_SRC}" ]; then - APPIMAGE_REAL="$(readlink -f "${APPIMAGE:-$0}")" - CURRENT_VER="$(stat -c '%i:%Y' "${APPIMAGE_REAL}" 2>/dev/null || echo "unknown")" - MARKER="${PYTHON_DST}/.version" - - if [ ! -f "${MARKER}" ] || [ "$(cat "${MARKER}" 2>/dev/null)" != "${CURRENT_VER}" ]; then - echo "Syncing Python runtime to ${PYTHON_DST}..." - rm -rf "${PYTHON_DST}" - mkdir -p "${PYTHON_DST}" - (cd "${PYTHON_SRC}" && tar --exclude-vcs -cf - .) | (cd "${PYTHON_DST}" && tar -xf -) - echo "${CURRENT_VER}" > "${MARKER}" - fi -fi - export FLUORINE_ORIG_LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" export FLUORINE_ORIG_PATH="${PATH}" export FLUORINE_ORIG_XDG_DATA_DIRS="${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" export FLUORINE_ORIG_QT_PLUGIN_PATH="${QT_PLUGIN_PATH:-}" export PATH="${BIN}:${PATH}" -export LD_LIBRARY_PATH="${HERE}/usr/lib:${PYTHON_DST}/lib:${LD_LIBRARY_PATH:-}" +export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}" export MO2_BASE_DIR="${APPIMAGE_DIR}" export MO2_PLUGINS_DIR="${BIN}/plugins" export MO2_DLLS_DIR="${BIN}/dlls" -export MO2_PYTHON_DIR="${PYTHON_DST}" -unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME +unset PYTHONPATH PYTHONNOUSERSITE PYTHONHOME MO2_PYTHON_DIR export QT_PLUGIN_PATH="${HERE}/usr/plugins" export QT_QPA_PLATFORM_PLUGIN_PATH="${HERE}/usr/plugins/platforms" @@ -636,13 +506,11 @@ APPRUN fi export ARCH=x86_64 - export LINUXDEPLOY_EXCLUDE_MODULES="libpython" "${DEPLOY_DIR}/AppRun" \ --appdir "${APPDIR}" \ --output appimage \ --desktop-file "${APPDIR}/usr/share/applications/com.fluorine.manager.desktop" \ --icon-file "${APPDIR}/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png" \ - --exclude-library "libpython*" \ 2>&1 || { echo "WARNING: linuxdeploy AppImage generation failed" } diff --git a/libs/basic_games_native/CMakeLists.txt b/libs/basic_games_native/CMakeLists.txt new file mode 100644 index 0000000..58bad3b --- /dev/null +++ b/libs/basic_games_native/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.16) + +project(basic_games_native) + +add_subdirectory(src) diff --git a/libs/basic_games_native/src/CMakeLists.txt b/libs/basic_games_native/src/CMakeLists.txt new file mode 100644 index 0000000..136cdcf --- /dev/null +++ b/libs/basic_games_native/src/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.16) + +file(GLOB basic_games_native_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h +) + +add_library(basic_games_native SHARED ${basic_games_native_SOURCES}) +mo2_configure_plugin(basic_games_native NO_SOURCES WARNINGS OFF) +target_link_libraries(basic_games_native PRIVATE mo2::uibase) +mo2_install_plugin(basic_games_native) diff --git a/libs/basic_games_native/src/basicgameplugin.cpp b/libs/basic_games_native/src/basicgameplugin.cpp new file mode 100644 index 0000000..8c73e4c --- /dev/null +++ b/libs/basic_games_native/src/basicgameplugin.cpp @@ -0,0 +1,380 @@ +#include "basicgameplugin.h" +#include "steamutils.h" + +#include <uibase/versioninfo.h> + +#include <QDateTime> +#include <QDirIterator> +#include <QStandardPaths> + +// Simple ISaveGame implementation for basic games +class BasicSaveGame : public MOBase::ISaveGame +{ +public: + BasicSaveGame(const QString& filePath) + : m_filePath(filePath), m_fileInfo(filePath) + { + } + + QString getFilepath() const override { return m_filePath; } + + QDateTime getCreationTime() const override + { + return m_fileInfo.lastModified(); + } + + QString getName() const override + { + return m_fileInfo.completeBaseName(); + } + + QString getSaveGroupIdentifier() const override { return ""; } + + QStringList allFiles() const override { return {m_filePath}; } + +private: + QString m_filePath; + QFileInfo m_fileInfo; +}; + +BasicGamePlugin::BasicGamePlugin(const GameDefinition& def) : m_def(def) {} + +bool BasicGamePlugin::init(MOBase::IOrganizer* organizer) +{ + m_organizer = organizer; + return true; +} + +QString BasicGamePlugin::name() const +{ + return m_def.pluginName; +} + +QString BasicGamePlugin::localizedName() const +{ + return m_def.pluginName + " (Native)"; +} + +QString BasicGamePlugin::author() const +{ + return m_def.author; +} + +QString BasicGamePlugin::description() const +{ + return "Adds support for " + m_def.gameName; +} + +MOBase::VersionInfo BasicGamePlugin::version() const +{ + return MOBase::VersionInfo(m_def.version); +} + +QList<MOBase::PluginSetting> BasicGamePlugin::settings() const +{ + return {}; +} + +QString BasicGamePlugin::gameName() const +{ + return m_def.gameName; +} + +void BasicGamePlugin::detectGame() +{ + // Try Steam first + for (int steamId : m_def.steamAppIds) { + QString path = findSteamGamePath(steamId); + if (!path.isEmpty()) { + setGamePath(path); + return; + } + } + + // GOG via Heroic launcher + if (!m_def.gogAppIds.isEmpty()) { + // Check Heroic GOG installed games + QStringList heroicPaths = { + QDir::homePath() + "/.config/heroic/gog_store/installed.json", + QDir::homePath() + + "/.var/app/com.heroicgameslauncher.hgl/config/heroic/" + "gog_store/installed.json", + }; + for (const auto& heroicPath : heroicPaths) { + QFile file(heroicPath); + if (!file.open(QIODevice::ReadOnly)) + continue; + QByteArray data = file.readAll(); + // Simple JSON parsing for install_path + for (int gogId : m_def.gogAppIds) { + QString idStr = QString::number(gogId); + if (data.contains(idStr.toUtf8())) { + // Find the install_path for this entry + int idx = data.indexOf(idStr.toUtf8()); + int pathIdx = data.indexOf("install_path", idx); + if (pathIdx >= 0) { + // Find the value after "install_path" + int colonIdx = data.indexOf(':', pathIdx); + int quoteStart = data.indexOf('"', colonIdx + 1); + int quoteEnd = data.indexOf('"', quoteStart + 1); + if (quoteStart >= 0 && quoteEnd > quoteStart) { + QString path = + QString::fromUtf8(data.mid(quoteStart + 1, quoteEnd - quoteStart - 1)); + if (QDir(path).exists()) { + setGamePath(path); + return; + } + } + } + } + } + } + } +} + +void BasicGamePlugin::initializeProfile(const QDir& directory, + ProfileSettings settings) const +{ + // Create the profile directory if needed + if (!directory.exists()) { + directory.mkpath("."); + } +} + +std::vector<std::shared_ptr<const MOBase::ISaveGame>> +BasicGamePlugin::listSaves(QDir folder) const +{ + std::vector<std::shared_ptr<const MOBase::ISaveGame>> saves; + + if (!m_def.saveExtension.isEmpty() && folder.exists()) { + QStringList filters; + filters << "*." + m_def.saveExtension; + QStringList entries = folder.entryList(filters, QDir::Files, QDir::Time); + for (const auto& entry : entries) { + saves.push_back( + std::make_shared<BasicSaveGame>(folder.filePath(entry))); + } + } + + return saves; +} + +bool BasicGamePlugin::isInstalled() const +{ + return m_installed; +} + +QIcon BasicGamePlugin::gameIcon() const +{ + return QIcon(); +} + +QDir BasicGamePlugin::gameDirectory() const +{ + return QDir(m_gameDir); +} + +QDir BasicGamePlugin::dataDirectory() const +{ + QString dataDir = resolveVariables(m_def.dataDirectory); + if (dataDir.isEmpty()) { + return gameDirectory(); + } + QDir dir(dataDir); + if (dir.isAbsolute()) { + return dir; + } + return QDir(m_gameDir + "/" + dataDir); +} + +void BasicGamePlugin::setGamePath(const QString& path) +{ + m_gameDir = path; + m_installed = !path.isEmpty() && QDir(path).exists(); +} + +QDir BasicGamePlugin::documentsDirectory() const +{ + if (m_def.documentsDirectory.isEmpty()) { + // Default: try My Games/<gameName> then <gameName> under Documents + QString docs = + QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + QDir myGames(docs + "/My Games/" + m_def.gameName); + if (myGames.exists()) + return myGames; + QDir plain(docs + "/" + m_def.gameName); + if (plain.exists()) + return plain; + return QDir(); + } + return QDir(resolveVariables(m_def.documentsDirectory)); +} + +QDir BasicGamePlugin::savesDirectory() const +{ + if (m_def.savesDirectory.isEmpty()) { + return documentsDirectory(); + } + return QDir(resolveVariables(m_def.savesDirectory)); +} + +QList<MOBase::ExecutableInfo> BasicGamePlugin::executables() const +{ + QList<MOBase::ExecutableInfo> list; + QDir dir = gameDirectory(); + QFileInfo binary(dir.filePath(m_def.binaryName)); + if (binary.exists()) { + list.append(MOBase::ExecutableInfo(m_def.gameName, binary)); + } + if (!m_def.launcherName.isEmpty()) { + QFileInfo launcher(dir.filePath(m_def.launcherName)); + if (launcher.exists()) { + list.append(MOBase::ExecutableInfo(m_def.gameName + " Launcher", launcher)); + } + } + return list; +} + +QList<MOBase::ExecutableForcedLoadSetting> +BasicGamePlugin::executableForcedLoads() const +{ + return {}; +} + +QString BasicGamePlugin::steamAPPId() const +{ + if (!m_def.steamAppIds.isEmpty()) { + return QString::number(m_def.steamAppIds.first()); + } + return ""; +} + +QStringList BasicGamePlugin::primaryPlugins() const +{ + return m_def.primaryPlugins; +} + +QStringList BasicGamePlugin::gameVariants() const +{ + return {}; +} + +void BasicGamePlugin::setGameVariant(const QString&) {} + +QString BasicGamePlugin::binaryName() const +{ + return m_def.binaryName; +} + +QString BasicGamePlugin::gameShortName() const +{ + return m_def.gameShortName; +} + +QStringList BasicGamePlugin::validShortNames() const +{ + return m_def.validShortNames; +} + +QString BasicGamePlugin::gameNexusName() const +{ + return m_def.gameNexusName; +} + +QStringList BasicGamePlugin::iniFiles() const +{ + QStringList resolved; + for (const auto& ini : m_def.iniFiles) { + resolved.append(resolveVariables(ini)); + } + return resolved; +} + +QStringList BasicGamePlugin::DLCPlugins() const +{ + return m_def.dlcPlugins; +} + +MOBase::IPluginGame::LoadOrderMechanism +BasicGamePlugin::loadOrderMechanism() const +{ + return m_def.loadOrderMechanism; +} + +MOBase::IPluginGame::SortMechanism BasicGamePlugin::sortMechanism() const +{ + return m_def.sortMechanism; +} + +int BasicGamePlugin::nexusGameID() const +{ + return m_def.nexusGameId; +} + +bool BasicGamePlugin::looksValid(QDir const& dir) const +{ + return dir.exists(m_def.binaryName); +} + +QString BasicGamePlugin::gameVersion() const +{ + // On Linux we can't easily read PE version resources + // Return empty; MO2 will show "N/A" + return ""; +} + +QString BasicGamePlugin::getLauncherName() const +{ + return m_def.launcherName; +} + +QString BasicGamePlugin::getSupportURL() const +{ + return m_def.supportURL; +} + +QString BasicGamePlugin::resolveVariables(const QString& input) const +{ + if (input.isEmpty()) + return input; + + QString result = input; + + // Normalize backslashes to forward slashes first + result.replace('\\', '/'); + + // %DOCUMENTS% + if (result.contains("%DOCUMENTS%")) { + QString docs = + QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + result.replace("%DOCUMENTS%", docs); + } + + // %USERPROFILE% - on Linux, resolve to the Wine prefix userprofile + if (result.contains("%USERPROFILE%")) { + QString userProfile; + // Try the global Fluorine prefix + QString prefixPath = + QDir::homePath() + + "/.local/share/fluorine/Prefix/pfx/drive_c/users/steamuser"; + if (QDir(prefixPath).exists()) { + userProfile = prefixPath; + } else { + // Fallback to home directory + userProfile = QDir::homePath(); + } + result.replace("%USERPROFILE%", userProfile); + } + + // %GAME_PATH% + if (result.contains("%GAME_PATH%")) { + result.replace("%GAME_PATH%", m_gameDir); + } + + // %GAME_DOCUMENTS% + if (result.contains("%GAME_DOCUMENTS%")) { + result.replace("%GAME_DOCUMENTS%", documentsDirectory().absolutePath()); + } + + return result; +} diff --git a/libs/basic_games_native/src/basicgameplugin.h b/libs/basic_games_native/src/basicgameplugin.h new file mode 100644 index 0000000..17f4ec9 --- /dev/null +++ b/libs/basic_games_native/src/basicgameplugin.h @@ -0,0 +1,73 @@ +#ifndef BASICGAMEPLUGIN_H +#define BASICGAMEPLUGIN_H + +#include "gamedefs.h" + +#include <uibase/iplugingame.h> +#include <uibase/imoinfo.h> + +#include <QDir> +#include <QIcon> +#include <QString> + +class BasicGamePlugin : public MOBase::IPluginGame +{ + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginGame) + +public: + explicit BasicGamePlugin(const GameDefinition& def); + + // IPlugin + bool init(MOBase::IOrganizer* organizer) override; + QString name() const override; + QString localizedName() const override; + QString author() const override; + QString description() const override; + MOBase::VersionInfo version() const override; + QList<MOBase::PluginSetting> settings() const override; + + // IPluginGame + QString gameName() const override; + void detectGame() override; + void initializeProfile(const QDir& directory, + ProfileSettings settings) const override; + std::vector<std::shared_ptr<const MOBase::ISaveGame>> + listSaves(QDir folder) const override; + bool isInstalled() const override; + QIcon gameIcon() const override; + QDir gameDirectory() const override; + QDir dataDirectory() const override; + void setGamePath(const QString& path) override; + QDir documentsDirectory() const override; + QDir savesDirectory() const override; + QList<MOBase::ExecutableInfo> executables() const override; + QList<MOBase::ExecutableForcedLoadSetting> executableForcedLoads() const override; + QString steamAPPId() const override; + QStringList primaryPlugins() const override; + QStringList gameVariants() const override; + void setGameVariant(const QString& variant) override; + QString binaryName() const override; + QString gameShortName() const override; + QStringList validShortNames() const override; + QString gameNexusName() const override; + QStringList iniFiles() const override; + QStringList DLCPlugins() const override; + LoadOrderMechanism loadOrderMechanism() const override; + SortMechanism sortMechanism() const override; + int nexusGameID() const override; + bool looksValid(QDir const& dir) const override; + QString gameVersion() const override; + QString getLauncherName() const override; + QString getSupportURL() const override; + +private: + QString resolveVariables(const QString& input) const; + + GameDefinition m_def; + MOBase::IOrganizer* m_organizer = nullptr; + QString m_gameDir; + bool m_installed = false; +}; + +#endif // BASICGAMEPLUGIN_H diff --git a/libs/basic_games_native/src/basicgamesproxy.cpp b/libs/basic_games_native/src/basicgamesproxy.cpp new file mode 100644 index 0000000..f7efefd --- /dev/null +++ b/libs/basic_games_native/src/basicgamesproxy.cpp @@ -0,0 +1,99 @@ +#include "basicgamesproxy.h" +#include "basicgameplugin.h" +#include "gamedefs.h" + +#include <uibase/versioninfo.h> + +BasicGamesProxy::BasicGamesProxy() {} + +BasicGamesProxy::~BasicGamesProxy() +{ + // Clean up all loaded plugins + for (auto& list : m_loaded) { + qDeleteAll(list); + } + m_loaded.clear(); +} + +bool BasicGamesProxy::init(MOBase::IOrganizer* organizer) +{ + m_organizer = organizer; + return true; +} + +QString BasicGamesProxy::name() const +{ + return "Basic Games Native"; +} + +QString BasicGamesProxy::author() const +{ + return "Fluorine Manager"; +} + +QString BasicGamesProxy::description() const +{ + return "Native C++ implementation of basic game plugins"; +} + +MOBase::VersionInfo BasicGamesProxy::version() const +{ + return MOBase::VersionInfo(1, 0, 0); +} + +QList<MOBase::PluginSetting> BasicGamesProxy::settings() const +{ + return {}; +} + +QStringList BasicGamesProxy::pluginList(const QDir&) const +{ + QStringList list; + const auto& defs = allGameDefinitions(); + for (size_t i = 0; i < defs.size(); ++i) { + list.append(QString("native_game_%1").arg(i)); + } + return list; +} + +QList<QObject*> BasicGamesProxy::load(const QString& identifier) +{ + // Already loaded? + auto it = m_loaded.find(identifier); + if (it != m_loaded.end()) { + return it.value(); + } + + QList<QObject*> plugins; + + // Parse the index from the identifier + if (!identifier.startsWith("native_game_")) + return plugins; + + bool ok = false; + int index = identifier.mid(12).toInt(&ok); + if (!ok) + return plugins; + + const auto& defs = allGameDefinitions(); + if (index < 0 || index >= static_cast<int>(defs.size())) + return plugins; + + auto* plugin = new BasicGamePlugin(defs[index]); + if (m_organizer) { + plugin->init(m_organizer); + } + plugins.append(plugin); + + m_loaded.insert(identifier, plugins); + return plugins; +} + +void BasicGamesProxy::unload(const QString& identifier) +{ + auto it = m_loaded.find(identifier); + if (it != m_loaded.end()) { + qDeleteAll(it.value()); + m_loaded.erase(it); + } +} diff --git a/libs/basic_games_native/src/basicgamesproxy.h b/libs/basic_games_native/src/basicgamesproxy.h new file mode 100644 index 0000000..2c2858f --- /dev/null +++ b/libs/basic_games_native/src/basicgamesproxy.h @@ -0,0 +1,41 @@ +#ifndef BASICGAMESPROXY_H +#define BASICGAMESPROXY_H + +#include <uibase/ipluginproxy.h> + +#include <QHash> +#include <QList> +#include <QObject> +#include <QString> + +class BasicGamesProxy : public QObject, public MOBase::IPluginProxy +{ + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginProxy) +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + Q_PLUGIN_METADATA(IID "com.tannin.ModOrganizer.PluginProxy/1.0") +#endif + +public: + BasicGamesProxy(); + ~BasicGamesProxy() override; + + // IPlugin + bool init(MOBase::IOrganizer* organizer) override; + QString name() const override; + QString author() const override; + QString description() const override; + MOBase::VersionInfo version() const override; + QList<MOBase::PluginSetting> settings() const override; + + // IPluginProxy + QStringList pluginList(const QDir& pluginPath) const override; + QList<QObject*> load(const QString& identifier) override; + void unload(const QString& identifier) override; + +private: + MOBase::IOrganizer* m_organizer = nullptr; + QHash<QString, QList<QObject*>> m_loaded; +}; + +#endif // BASICGAMESPROXY_H diff --git a/libs/basic_games_native/src/gamedefs.cpp b/libs/basic_games_native/src/gamedefs.cpp new file mode 100644 index 0000000..d626d23 --- /dev/null +++ b/libs/basic_games_native/src/gamedefs.cpp @@ -0,0 +1,1024 @@ +#include "gamedefs.h" + +// clang-format off +const std::vector<GameDefinition>& allGameDefinitions() +{ + static const std::vector<GameDefinition> defs = { + // 1. Batman: Arkham City + { + "Batman: Arkham City Plugin", "Paynamia", "0.5.3", + "Batman: Arkham City", "batmanarkhamcity", {}, + {200260}, {1260066469}, {"Egret"}, + "Binaries/Win32/BatmanAC.exe", "Binaries/Win32/BmLauncher.exe", + "", 372, + "BmGame", + "%DOCUMENTS%/WB Games/Batman Arkham City GOTY/BmGame/Config", + "", "sgd", + {"UserEngine.ini", "UserGame.ini", "UserInput.ini"}, + "", {}, {}, {}, {} + }, + // 2. Assetto Corsa + { + "Assetto Corsa Support Plugin", "Deorder", "0.0.1", + "Assetto Corsa", "ac", {}, + {244210}, {}, {}, + "AssettoCorsa.exe", "", + "", 0, + "", + "%DOCUMENTS%/Assetto Corsa", + "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Assetto-Corsa", + {}, {}, {}, {} + }, + // 3. Baldur's Gate 3 + { + "Baldur's Gate 3 Plugin", "daescha", "0.1.0", + "Baldur's Gate 3", "baldursgate3", {"bg3"}, + {1086940}, {1456460669}, {}, + "bin/bg3.exe", "Launcher/LariLauncher.exe", + "baldursgate3", 3474, + "", + "%USERPROFILE%/AppData/Local/Larian Studios/Baldur's Gate 3", + "%GAME_DOCUMENTS%/PlayerProfiles/Public/Savegames/Story", "lsv", + {}, + "", + MOBase::IPluginGame::LoadOrderMechanism::PluginsTxt, + MOBase::IPluginGame::SortMechanism::NONE, + {}, {} + }, + // 4. Black & White 2 + { + "Black & White 2 Support Plugin", "Ilyu", "1.0.1", + "Black & White 2", "BW2", {}, + {}, {}, {}, + "white.exe", "", + "blackandwhite2", 0, + "%GAME_PATH%", + "%DOCUMENTS%/Black & White 2", + "%GAME_DOCUMENTS%/Profiles", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Black-&-White-2", + {}, {}, {}, {} + }, + // 5. Black & White 2 Battle of the Gods + { + "Black & White 2 Battle of the Gods Support Plugin", "Ilyu", "1.0.1", + "Black & White 2 Battle of the Gods", "BOTG", {}, + {}, {}, {}, + "BattleOfTheGods.exe", "", + "blackandwhite2", 0, + "%GAME_PATH%", + "%DOCUMENTS%/Black & White 2 - Battle of the Gods", + "%GAME_DOCUMENTS%/Profiles", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Black-&-White-2", + {}, {}, {}, {} + }, + // 6. Blade & Sorcery + { + "Blade & Sorcery Plugin", "R3z Shark & Silarn & Jonny_Bro", "0.5.1", + "Blade & Sorcery", "bladeandsorcery", {}, + {629730}, {}, {}, + "BladeAndSorcery.exe", "", + "", 0, + "BladeAndSorcery_Data/StreamingAssets/Mods", + "%DOCUMENTS%/My Games/BladeAndSorcery", + "%GAME_DOCUMENTS%/Saves/Default", "chr", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Blade-&-Sorcery", + {}, {}, {}, {} + }, + // 7. Borderlands + { + "Borderlands 1 Support Plugin", "Miner Of Worlds, RedxYeti, mopioid", "1.0.0", + "Borderlands", "Borderlands", {}, + {8980}, {}, {}, + "Binaries/Borderlands.exe", "", + "Borderlands GOTY", 0, + ".", + "%DOCUMENTS%/My Games/Borderlands", + "%GAME_DOCUMENTS%/savedata", "sav", + {}, + "", {}, {}, {}, {} + }, + // 8. Control + { + "Control Support Plugin", "Zash", "1.0.0", + "Control", "control", {}, + {870780}, {2049187585}, {"calluna"}, + "Control.exe", "", + "", 2936, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 9. Cyberpunk 2077 + { + "Cyberpunk 2077 Support Plugin", "6788, Zash", "3.0.1", + "Cyberpunk 2077", "cyberpunk2077", {}, + {1091500}, {1423049311}, {"77f2b98e2cef40c8a7437518bf420e47"}, + "bin/x64/Cyberpunk2077.exe", "REDprelauncher.exe", + "", 0, + "%GAME_PATH%", + "%USERPROFILE%/AppData/Local/CD Projekt Red/Cyberpunk 2077", + "%USERPROFILE%/Saved Games/CD Projekt Red/Cyberpunk 2077", "dat", + {"UserSettings.json"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Cyberpunk-2077", + {}, {}, {}, {} + }, + // 10. Dragon Age 2 + { + "Dragon Age 2 Support Plugin", "Patchier", "1.0.1", + "Dragon Age 2", "dragonage2", {}, + {1238040}, {}, {}, + "bin_ship/DragonAge2.exe", "", + "", 0, + "%DOCUMENTS%/BioWare/Dragon Age 2/packages/core/override", + "", + "%DOCUMENTS%/BioWare/Dragon Age 2/Characters", "das", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dragon-Age-II", + {}, {}, {}, {} + }, + // 11. Daggerfall Unity + { + "Daggerfall Unity Support Plugin", "HomerSimpleton", "1.0.0", + "Daggerfall Unity", "daggerfallunity", {}, + {}, {}, {}, + "DaggerfallUnity.exe", "DaggerfallUnity.exe", + "", 0, + "%GAME_PATH%/DaggerfallUnity_Data/StreamingAssets", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Daggerfall-Unity", + {}, {}, {}, {} + }, + // 12. Dragon Age: Origins + { + "Dragon Age Origins Support Plugin", "Patchier", "1.1.1", + "Dragon Age: Origins", "dragonage", {}, + {17450, 47810}, {1949616134}, {}, + "bin_ship/DAOrigins.exe", "", + "", 0, + "%DOCUMENTS%/BioWare/Dragon Age/packages/core/override", + "", + "%DOCUMENTS%/BioWare/Dragon Age/Characters", "das", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dragon-Age:-Origins", + {}, {}, {}, {} + }, + // 13. Darkest Dungeon + { + "DarkestDungeon", "erri120", "0.2.0", + "Darkest Dungeon", "darkestdungeon", {}, + {262060}, {1719198803}, {}, + "_windows/win64/Darkest.exe", "", + "darkestdungeon", 804, + "", + "%DOCUMENTS%/Darkest", + "%GAME_DOCUMENTS%", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Darkest-Dungeon", + {}, {}, {}, {} + }, + // 14. Dark Messiah of Might & Magic + { + "Dark Messiah of Might and Magic Support Plugin", "Holt59", "0.1.0", + "Dark Messiah of Might & Magic", "darkmessiahofmightandmagic", {}, + {2100}, {}, {}, + "mm.exe", "", + "darkmessiahofmightandmagic", 628, + "mm", + "%GAME_PATH%/mm", + "%GAME_PATH%/mm/SAVE", "sav", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dark-Messiah-of-Might-&-Magic", + {}, {}, {}, {} + }, + // 15. Dark Souls II: Scholar of the First Sin + { + "DarkSouls2Sotfs", "raehik", "0.1.0", + "Dark Souls II: Scholar of the First Sin", "darksouls2sotfs", {}, + {335300}, {}, {}, + "Game/DarkSoulsII.exe", "", + "darksouls2", 482, + "Game", + "%USERPROFILE%/AppData/Roaming/DarkSoulsII", + "%USERPROFILE%/AppData/Roaming/DarkSoulsII", "sl2", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dark-Souls-2-Sotfs", + {}, {}, {}, {} + }, + // 16. Dark Souls + { + "DarkSouls", "Holt59", "0.1.0", + "Dark Souls", "darksouls", {}, + {211420}, {}, {}, + "DATA/DARKSOULS.exe", "", + "darksouls", 162, + "DATA", + "%DOCUMENTS%/NBGI/DarkSouls", + "%DOCUMENTS%/NBGI/DarkSouls", "sl2", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dark-Souls", + {}, {}, {}, {} + }, + // 17. Dispatch + { + "Dispatch Support Plugin", "Syer10", "0.1.0", + "Dispatch", "dispatch", {}, + {2592160}, {}, {}, + "Dispatch/Binaries/Win64/Dispatch-Win64-Shipping.exe", "", + "dispatch", 0, + "Dispatch/Content/", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dispatch", + {}, {}, {}, {} + }, + // 18. Divinity: Original Sin (Enhanced Edition) + { + "Divinity: Original Sin (Enhanced Edition) Support Plugin", "LostDragonist", "1.0.0", + "Divinity: Original Sin (Enhanced Edition)", "divinityoriginalsinenhancededition", + {"divinityoriginalsin"}, + {373420}, {1445516929, 1445524575}, {}, + "Shipping/EoCApp.exe", "", + "divinityoriginalsinenhancededition", 1995, + "Data", + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin Enhanced Edition", + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin Enhanced Edition/PlayerProfiles", + "lsv", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Divinity:-Original-Sin", + {}, {}, {}, {} + }, + // 19. Divinity: Original Sin (Classic) + { + "Divinity: Original Sin (Classic) Support Plugin", "LostDragonist", "1.0.0", + "Divinity: Original Sin (Classic)", "divinityoriginalsin", + {"divinityoriginalsin"}, + {230230}, {}, {}, + "Shipping/EoCApp.exe", "", + "divinityoriginalsin", 573, + "Data", + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin", + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin/PlayerProfiles", + "lsv", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Divinity:-Original-Sin", + {}, {}, {}, {} + }, + // 20. Dragon's Dogma: Dark Arisen + { + "Dragon's Dogma: Dark Arisen Support Plugin", "Luca/EzioTheDeadPoet", "1.0.0", + "Dragon's Dogma: Dark Arisen", "dragonsdogma", {}, + {367500}, {1242384383}, {}, + "DDDA.exe", "", + "dragonsdogma", 0, + "nativePC", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dragon's-Dogma:-Dark-Arisen", + {}, {}, {}, {} + }, + // 21. Dungeon Siege I + { + "Dungeon Siege I", "mrudat", "0.0.1", + "Dungeon Siege I", "dungeonsiege1", {}, + {39190}, {1142020247}, {}, + "DungeonSiege.exe", "", + "dungeonsiege1", 541, + "", + "%DOCUMENTS%/Dungeon Siege", + "%GAME_DOCUMENTS%/Save", "dssave", + {"DungeonSiege.ini"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dungeon-Siege-I", + {}, {}, {}, {} + }, + // 22. Dungeon Siege II + { + "Dungeon Siege II", "Holt59", "0.1.1", + "Dungeon Siege II", "dungeonsiegeii", {}, + {39200}, {1142020247}, {}, + "DungeonSiege2.exe", "", + "dungeonsiegeii", 2078, + "", + "%DOCUMENTS%/My Games/Dungeon Siege 2", + "%GAME_DOCUMENTS%/Save", "ds2party", + {"DungeonSiege2.ini"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Dungeon-Siege-II", + {}, {}, {}, {} + }, + // 23. F1 23 + { + "F1 23 Support Plugin", "ju5tA1ex", "1.0.0", + "F1 23", "F1 23", {}, + {2108330}, {}, {}, + "F1_23.exe", "", + "", 0, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 24. Fantasy Life I + { + "Fantasy Life I Support Plugin", "AmeliaCute", "0.2.2", + "FANTASY LIFE i", "fantasylifei", {"fli"}, + {2993780}, {}, {}, + "Game/Binaries/Win64/NFL1-Win64-Shipping.exe", "", + "fantasylifeithegirlwhostealstime", 0, + "Game/Content/", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Fantasy-Life-I:-The-Girl-Who-Steals-Time", + {}, {}, {}, {} + }, + // 25. Final Fantasy VII Rebirth + { + "Final Fantasy 7 Rebirth Support Plugin", "diegofesanto, TheUnlocked", "0.0.1", + "Final Fantasy VII Rebirth", "finalfantasy7rebirth", {}, + {2909400}, {}, {}, + "End/Binaries/Win64/ff7rebirth_.exe", "", + "finalfantasy7rebirth", 0, + "_ROOT", + "", "", "sav", + {}, + "", {}, {}, {}, {} + }, + // 26. Final Fantasy VII Remake + { + "Final Fantasy VII Remake Support Plugin", "TheUnlocked", "1.0.0", + "Final Fantasy VII Remake", "finalfantasy7remake", {}, + {1462040}, {}, {}, + "ff7remake.exe", "", + "finalfantasy7remake", 0, + "_ROOT", + "", "", "sav", + {}, + "", {}, {}, {}, {} + }, + // 27. GTA III - Definitive Edition + { + "Grand Theft Auto III - Definitive Edition Support Plugin", "dekart811", "1.0", + "GTA III - Definitive Edition", "grandtheftautothetrilogy", {}, + {}, {}, {}, + "Gameface/Binaries/Win64/LibertyCity.exe", "", + "grandtheftautothetrilogy", 0, + "Gameface/Content/Paks/~mods", + "%USERPROFILE%/Documents/Rockstar Games/GTA III Definitive Edition/Config/WindowsNoEditor", + "%GAME_DOCUMENTS%/../../SaveGames", "sav", + {"GameUserSettings.ini", "CustomSettings.ini"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Grand-Theft-Auto:-The-Trilogy", + {}, {}, {}, {} + }, + // 28. GTA: San Andreas - Definitive Edition + { + "Grand Theft Auto: San Andreas - Definitive Edition Support Plugin", "dekart811", "1.0", + "GTA: San Andreas - Definitive Edition", "grandtheftautothetrilogy", {}, + {}, {}, {}, + "Gameface/Binaries/Win64/SanAndreas.exe", "", + "grandtheftautothetrilogy", 0, + "Gameface/Content/Paks/~mods", + "%USERPROFILE%/Documents/Rockstar Games/GTA San Andreas Definitive Edition/Config/WindowsNoEditor", + "%GAME_DOCUMENTS%/../../SaveGames", "sav", + {"GameUserSettings.ini", "CustomSettings.ini"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Grand-Theft-Auto:-The-Trilogy", + {}, {}, {}, {} + }, + // 29. GTA: Vice City - Definitive Edition + { + "Grand Theft Auto: Vice City - Definitive Edition Support Plugin", "dekart811", "1.0", + "GTA: Vice City - Definitive Edition", "grandtheftautothetrilogy", {}, + {}, {}, {}, + "Gameface/Binaries/Win64/ViceCity.exe", "", + "grandtheftautothetrilogy", 0, + "Gameface/Content/Paks/~mods", + "%USERPROFILE%/Documents/Rockstar Games/GTA Vice City Definitive Edition/Config/WindowsNoEditor", + "%GAME_DOCUMENTS%/../../SaveGames", "sav", + {"GameUserSettings.ini", "CustomSettings.ini"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Grand-Theft-Auto:-The-Trilogy", + {}, {}, {}, {} + }, + // 30. Kerbal Space Program + { + "Kerbal Space Program Support Plugin", "LaughingHyena", "1.0.0", + "Kerbal Space Program", "kerbalspaceprogram", {}, + {220200, 283740, 982970}, {}, {}, + "KSP_x64.exe", "", + "kerbalspaceprogram", 0, + "GameData", + "", + "%GAME_PATH%/saves", "sfs", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Kerbal-Space-Program", + {}, {}, {}, {} + }, + // 31. Kingdom Come: Deliverance + { + "Kingdom Come Deliverance Support Plugin", "Silencer711", "1.0.0", + "Kingdom Come: Deliverance", "kingdomcomedeliverance", {}, + {379430}, {1719198803}, {"Eel"}, + "bin/Win64/KingdomCome.exe", "", + "kingdomcomedeliverance", 2298, + "mods", + "%GAME_PATH%", + "%USERPROFILE%/Saved Games/kingdomcome/saves", "whs", + {"custom.cfg", "system.cfg", "user.cfg"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Kingdom-Come:-Deliverance", + {}, {}, {}, {} + }, + // 32. Yu-Gi-Oh! Master Duel + { + "Yu-Gi-Oh! Master Duel Support Plugin", "The Conceptionist & uwx", "1.0.2", + "Yu-Gi-Oh! Master Duel", "masterduel", {}, + {1449850}, {}, {}, + "masterduel.exe", "", + "yugiohmasterduel", 4272, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 33. METAL GEAR SOLID 2: Sons of Liberty - MC + { + "METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version Support Plugin", + "AkiraJkr", "1.0.0", + "METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version", + "metalgearsolid2mc", {}, + {2131640}, {}, {}, + "METAL GEAR SOLID2.exe", "launcher.exe", + "metalgearsolid2mc", 0, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 34. METAL GEAR SOLID 3: Snake Eater - MC + { + "METAL GEAR SOLID 3: Snake Eater - Master Collection Version Support Plugin", + "AkiraJkr", "1.0.0", + "METAL GEAR SOLID 3: Snake Eater - Master Collection Version", + "metalgearsolid3mc", {}, + {2131650}, {}, {}, + "METAL GEAR SOLID3.exe", "launcher.exe", + "metalgearsolid3mc", 0, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 35. Mirror's Edge + { + "Mirror's Edge Support Plugin", "Luca/EzioTheDeadPoet", "1.0.0", + "Mirror's Edge", "mirrorsedge", {}, + {17410}, {1893001152}, {}, + "Binaries/MirrorsEdge.exe", "", + "", 0, + "TdGame", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Mirror's-Edge", + {}, {}, {}, {} + }, + // 36. Monster Hunter: Rise + { + "Monster Hunter: Rise Support Plugin", "RodolfoFigueroa", "1.0.0", + "Monster Hunter: Rise", "monsterhunterrise", {}, + {1446780}, {}, {}, + "MonsterHunterRise.exe", "", + "", 4095, + "%GAME_PATH%", + "", "", "bin", + {}, + "", {}, {}, {}, {} + }, + // 37. Monster Hunter: Wilds + { + "Monster Hunter: Wilds Support Plugin", "AbyssDragnonModding", "1.0.0", + "Monster Hunter: Wilds", "monsterhunterwilds", {}, + {2246340}, {}, {}, + "MonsterHunterWilds.exe", "", + "", 6993, + "%GAME_PATH%", + "", "", "bin", + {}, + "", {}, {}, {}, {} + }, + // 38. Monster Hunter: World + { + "Monster Hunter: World Support Plugin", "prz", "1.0.0", + "Monster Hunter: World", "monsterhunterworld", {}, + {582010}, {}, {}, + "MonsterHunterWorld.exe", "MonsterHunterWorld.exe", + "monsterhunterworld", 2531, + "%GAME_PATH%", + "", "", "dat", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Monster-Hunter:-World", + {}, {}, {}, {} + }, + // 39. Mount & Blade II: Bannerlord + { + "Mount & Blade II: Bannerlord", "Holt59", "0.1.1", + "Mount & Blade II: Bannerlord", "mountandblade2bannerlord", {}, + {261550}, {}, {}, + "bin/Win64_Shipping_Client/TaleWorlds.MountAndBlade.Launcher.exe", "", + "", 3174, + "Modules", + "%DOCUMENTS%/Mount and Blade II Bannerlord/Configs", + "%DOCUMENTS%/Mount and Blade II Bannerlord/Game Saves", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Mount-&-Blade-II:-Bannerlord", + {}, {}, {}, {} + }, + // 40. Microsoft Flight Simulator 2020 + { + "Microsoft Flight Simulator 2020 Support Plugin", "Deorder", "0.0.1", + "Microsoft Flight Simulator 2020", "msfs2020", {}, + {1250410}, {}, {}, + "FlightSimulator.exe", "", + "", 0, + "", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Microsoft-Flight-Simulator-(2020)", + {}, {}, {}, {} + }, + // 41. Need for Speed: High Stakes + { + "Need for Speed: High Stakes Support Plugin", "uwx", "1.0.0", + "Need for Speed: High Stakes", "nfshs", {}, + {}, {}, {}, + "nfshs.exe", "", + "needforspeedhighstakes", 6032, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 42. NieR:Automata + { + "NieR:Automata Support Plugin", "Luca/EzioTheDeadPoet", "1.0.0", + "NieR:Automata", "nierautomata", {}, + {524220}, {}, {}, + "NieRAutomata.exe", "", + "nierautomata", 0, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 43. No Man's Sky + { + "No Man's Sky Support Plugin", "Luca/EzioTheDeadPoet", "1.0.0", + "No Man's Sky", "nomanssky", {}, + {275850}, {1446213994}, {}, + "Binaries/NMS.exe", "", + "nomanssky", 1634, + "GAMEDATA/MODS", + "", + "%USERPROFILE%/AppData/Roaming/HelloGames/NMS", "hg", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-No-Man's-Sky", + {}, {}, {}, {} + }, + // 44. Oblivion Remastered + { + "Oblivion Remastered Plugin", "Silarn", "0.1.0", + "Oblivion Remastered", "oblivionremastered", {}, + {2623190}, {}, {}, + "OblivionRemastered.exe", "", + "", 7587, + "%GAME_PATH%/OblivionRemastered/Content/Dev/ObvData/Data", + "%GAME_PATH%/OblivionRemastered/Content/Dev/ObvData", + "%DOCUMENTS%/My Games/Oblivion Remastered/Saved/SaveGames", "sav", + {"Oblivion.ini"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Elder-Scrolls-IV:-Oblivion-Remastered", + MOBase::IPluginGame::LoadOrderMechanism::PluginsTxt, + MOBase::IPluginGame::SortMechanism::LOOT, + {"Oblivion.esm", "DLCBattlehornCastle.esp", "DLCFrostcrag.esp", + "DLCHorseArmor.esp", "DLCMehrunesRazor.esp", "DLCOrrery.esp", + "DLCShiveringIsles.esp", "DLCSpellTomes.esp", "DLCThievesDen.esp", + "DLCVileLair.esp", "Knights.esp", "AltarESPMain.esp", + "AltarDeluxe.esp", "AltarESPLocal.esp"}, + {} + }, + // 45. Ready or Not + { + "Ready or Not Support Plugin", "Ra2-IFV", "0.0.0.1", + "Ready or Not", "readyornot", {"ron"}, + {1144200}, {}, {}, + "ReadyOrNot/Binaries/Win64/ReadyOrNot-Win64-Shipping.exe", "ReadyOrNot.exe", + "readyornot", 0, + "ReadyOrNot/Content/Paks", + "%USERPROFILE%/AppData/Local/ReadyOrNot", + "%USERPROFILE%/AppData/Local/ReadyOrNot/Saved/SaveGames", "sav", + {"%GAME_DOCUMENTS%/Saved/Config/Windows/Game.ini", + "%GAME_DOCUMENTS%/Saved/Config/Windows/GameUserSettings.ini"}, + "", {}, {}, {}, {} + }, + // 46. Schedule I + { + "Schedule I Support Plugin", "shellbj", "1.0.0", + "Schedule I", "scheduleI", {"schedule1", "scheduleI"}, + {3164500}, {}, {}, + "Schedule I.exe", "", + "schedule1", 7381, + "", + "", + "%USERPROFILE%/AppData/LocalLow/TVGS/Schedule I/Saves", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Schedule-I", + {}, {}, {}, {} + }, + // 47. Sekiro: Shadows Die Twice + { + "Sekiro: Shadows Die Twice Support Plugin", "Kane Dou", "1.0.0", + "Sekiro: Shadows Die Twice", "sekiro", {}, + {814380}, {}, {}, + "sekiro.exe", "", + "", 0, + "mods", + "", "", "sl2", + {}, + "", {}, {}, {}, {} + }, + // 48. Silent Hill 2 Remake + { + "Silent Hill 2 Remake Support Plugin", "HomerSimpleton Returns", "1.0", + "Silent Hill 2 Remake", "silenthill2", {}, + {}, {1225972913, 2051029707}, {}, + "SHProto/Binaries/Win64/SHProto-Win64-Shipping.exe", "SHProto.exe", + "silenthill2", 0, + "%GAME_PATH%", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Silent-Hill-2-Remake", + {}, {}, {}, {} + }, + // 49. Hollow Knight: Silksong + { + "Hollow Knight: Silksong Support Plugin", "Nikirack", "1.0.0", + "Hollow Knight: Silksong", "hollowknightsilksong", {}, + {1030300}, {}, {}, + "Hollow Knight Silksong.exe", "", + "hollowknightsilksong", 0, + "", + "", + "%USERPROFILE%/AppData/LocalLow/Team Cherry/Hollow Knight Silksong", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Hollow-Knight-Silksong", + {}, {}, {}, {} + }, + // 50. The Sims 4 + { + "The Sims 4 Support Plugin", "R3z Shark, xieve", "1.0.0", + "The Sims 4", "thesims4", {}, + {1222670}, {}, {}, + "Game/Bin/TS4_x64.exe", "", + "", 0, + "%DOCUMENTS%/Electronic Arts/The Sims 4/Mods", + "%DOCUMENTS%/Electronic Arts/The Sims 4/Mods", + "", "", + {}, + "", {}, {}, {}, {} + }, + // 51. STALKER Anomaly + { + "STALKER Anomaly", "Qudix", "0.5.0", + "STALKER Anomaly", "stalkeranomaly", {}, + {}, {}, {}, + "AnomalyLauncher.exe", "", + "stalkeranomaly", 3743, + "", + "%GAME_PATH%/appdata", + "%GAME_DOCUMENTS%/savedgames", "scop", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-S.T.A.L.K.E.R.-Anomaly", + {}, {}, {}, {} + }, + // 52. Stardew Valley + { + "Stardew Valley Support Plugin", "Syer10", "0.1.0", + "Stardew Valley", "stardewvalley", {}, + {413150}, {1453375253}, {}, + "Stardew Valley.exe", "", + "stardewvalley", 1303, + "mods", + "%DOCUMENTS%/StardewValley", + "%GAME_DOCUMENTS%/Saves", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Stardew-Valley", + {}, {}, {}, {} + }, + // 53. Starsector + { + "Starsector Support Plugin", "ddbb07", "1.0.1", + "Starsector", "starsector", {}, + {}, {}, {}, + "starsector.exe", "", + "starsector", 0, + "mods", + "", + "%GAME_PATH%/saves", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Starsector", + {}, {}, {}, {} + }, + // 54. STAR WARS Empire at War: Forces of Corruption + { + "STAR WARS Empire at War - Force of Corruption", "erri120", "1.0.0", + "STAR WARS Empire at War: Forces of Corruption", "starwarsempireatwar", {}, + {32470}, {1421404887}, {}, + "corruption/StarWarsG.exe", "", + "starwarsempireatwar", 453, + "corruption/Data", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Star-Wars:-Empire-At-War", + {}, {}, {}, {} + }, + // 55. STAR WARS Empire at War (base) + { + "STAR WARS Empire at War", "erri120", "1.0.0", + "STAR WARS Empire at War", "starwarsempireatwar", {}, + {32470}, {1421404887}, {}, + "GameData/StarWarsG.exe", "", + "starwarsempireatwar", 453, + "GameData/Data", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Star-Wars:-Empire-At-War", + {}, {}, {}, {} + }, + // 56. Subnautica: Below Zero + { + "Subnautica Below Zero Support Plugin", "dekart811, Zash", "2.3", + "Subnautica: Below Zero", "subnauticabelowzero", {}, + {848450}, {}, {"foxglove"}, + "SubnauticaZero.exe", "", + "subnauticabelowzero", 0, + "_ROOT", + "%GAME_PATH%", + "%USERPROFILE%/AppData/LocalLow/Unknown Worlds/Subnautica Below Zero/SubnauticaZero/SavedGames", + "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Subnautica:-Below-Zero", + {}, {}, {}, {} + }, + // 57. Subnautica + { + "Subnautica Support Plugin", "dekart811, Zash", "2.3", + "Subnautica", "subnautica", {}, + {264710}, {}, {"Jaguar"}, + "Subnautica.exe", "", + "subnautica", 0, + "_ROOT", + "%GAME_PATH%", + "%GAME_PATH%/SNAppData/SavedGames", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Subnautica", + {}, {}, {}, {} + }, + // 58. Test Drive Unlimited 2 + { + "Test Drive Unlimited 2 Support Plugin", "uwx", "1.0.0", + "Test Drive Unlimited 2", "tdu2", {}, + {9930}, {}, {}, + "UpLauncher.exe", "", + "testdriveunlimited2", 2353, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 59. Test Drive Unlimited + { + "Test Drive Unlimited Support Plugin", "uwx", "1.0.0", + "Test Drive Unlimited", "tdu", {}, + {}, {}, {}, + "TestDriveUnlimited.exe", "", + "testdriveunlimited", 4615, + "", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 60. The Binding of Isaac: Rebirth + { + "The Binding of Isaac: Rebirth - Support Plugin", "Luca/EzioTheDeadPoet", "0.1.0", + "The Binding of Isaac: Rebirth", "thebindingofisaacrebirth", {}, + {250900}, {}, {}, + "isaac-ng.exe", "", + "thebindingofisaacrebirth", 1293, + "%DOCUMENTS%/My Games/Binding of Isaac Afterbirth+ Mods", + "%DOCUMENTS%/My Games/Binding of Isaac Afterbirth+", + "", "", + {"options.ini"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-The-Binding-of-Isaac:-Rebirth", + {}, {}, {}, {} + }, + // 61. Tony Hawk's Pro Skater 3 + { + "Tony Hawk's Pro Skater 3 Support Plugin", "uwx", "1.0.0", + "Tony Hawk's Pro Skater 3", "thps3", {}, + {}, {}, {}, + "Skate3.exe", "", + "", 0, + "Data", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 62. Tony Hawk's Pro Skater 4 + { + "Tony Hawk's Pro Skater 4 Support Plugin", "uwx", "1.0.0", + "Tony Hawk's Pro Skater 4", "thps4", {}, + {}, {}, {}, + "Skate4.exe", "", + "", 0, + "Data", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 63. Tony Hawk's Underground 2 + { + "Tony Hawk's Underground 2 Support Plugin", "uwx", "1.0.0", + "Tony Hawk's Underground 2", "thug2", {}, + {}, {}, {}, + "THUG2.exe", "", + "", 0, + "Data", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 64. Tony Hawk's Underground + { + "Tony Hawk's Underground Support Plugin", "uwx", "1.0.0", + "Tony Hawk's Underground", "thug", {}, + {}, {}, {}, + "THUG.exe", "", + "", 0, + "Data", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 65. Trackmania United Forever + { + "Trackmania United Forever Support Plugin", "uwx", "1.0.0", + "Trackmania United Forever", "tmuf", {}, + {7200}, {}, {}, + "TmForeverLauncher.exe", "", + "trackmaniaunited", 1500, + "GameData", + "", "", "", + {}, + "", {}, {}, {}, {} + }, + // 66. Train Simulator + { + "Train Simulator Classic Support Plugin", "Ryan Young", "1.1.0", + "Train Simulator", "railworks", {}, + {24010}, {}, {}, + "RailWorks.exe", "", + "", 0, + "", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Train-Simulator-Classic", + {}, {}, {}, {} + }, + // 67. Valheim + { + "Valheim Support Plugin", "Zash", "1.3", + "Valheim", "valheim", {}, + {892970, 896660, 1223920}, {}, {}, + "valheim.exe", "", + "", 3667, + "", + "", + "%USERPROFILE%/AppData/LocalLow/IronGate/Valheim", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Valheim", + {}, {}, {}, {} + }, + // 68. Valkyria Chronicles + { + "Valkyria Chronicles Support Plugin", "Ketsuban", "1.0.0", + "Valkyria Chronicles", "vc1", {}, + {294860}, {}, {}, + "Valkyria.exe", "Launcher.exe", + "", 0, + "%GAME_PATH%", + "", + "%GAME_PATH%/savedata", "", + {}, + "", {}, {}, {}, {} + }, + // 69. Vampire - The Masquerade: Bloodlines + { + "Vampire - The Masquerade: Bloodlines Support Plugin", "John", "1.0.0", + "Vampire - The Masquerade: Bloodlines", "vampirebloodlines", {}, + {2600}, {1207659240}, {}, + "vampire.exe", "", + "vampirebloodlines", 437, + "vampire", + "%GAME_PATH%/vampire/cfg", + "%GAME_PATH%/vampire/SAVE", "sav", + {"autoexec.cfg", "user.cfg"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Vampire:-The-Masquerade-Bloodlines", + {}, {}, {}, {} + }, + // 70. The Witcher: Enhanced Edition + { + "Witcher 1 Support Plugin", "erri120", "1.0.0", + "The Witcher: Enhanced Edition", "witcher", {}, + {20900}, {1207658924}, {}, + "System/witcher.exe", "", + "witcher", 150, + "Data", + "%DOCUMENTS%/The Witcher", + "%GAME_DOCUMENTS%/saves", "TheWitcherSave", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-The-Witcher", + {}, {}, {}, {} + }, + // 71. The Witcher 2: Assassins of Kings + { + "Witcher 2 Support Plugin", "DefinitelyNotSade", "1.0.0", + "The Witcher 2: Assassins of Kings", "witcher2", {}, + {20920}, {1207658930}, {}, + "bin/witcher2.exe", "Launcher.exe", + "witcher2", 0, + "CookedPC", + "%DOCUMENTS%/witcher 2/Config", + "%GAME_DOCUMENTS%/../gamesaves", "sav", + {"User.ini", "Rendering.ini", "Community.ini", "UserContent.ini", + "DIMapping.ini", "Input_QWERTY.ini", "Input_AZERTY.ini", "Input_QWERTZ.ini"}, + "", {}, {}, {}, {} + }, + // 72. The Witcher 3: Wild Hunt + { + "Witcher 3 Support Plugin", "Holt59", "1.0.0", + "The Witcher 3: Wild Hunt", "witcher3", {}, + {499450, 292030}, {1640424747, 1495134320, 1207664663, 1207664643}, {}, + "bin/x64/witcher3.exe", "", + "witcher3", 952, + "Mods", + "%DOCUMENTS%/The Witcher 3", + "%GAME_DOCUMENTS%/gamesaves", "sav", + {"user.settings", "input.settings"}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-The-Witcher-3", + {}, {}, {}, {} + }, + // 73. X4: Foundations + { + "X4 Foundations Support Plugin", "Twinki,BrandonM4", "0.1.0", + "X4: Foundations", "x4foundations", {}, + {392160}, {}, {}, + "x4.exe", "", + "", 2659, + "extensions", + "%DOCUMENTS%/Egosoft/X4", + "", "", + {}, + "", {}, {}, {}, {} + }, + // 74. X-Plane 11 + { + "X-Plane 11 Support Plugin", "Deorder", "0.0.1", + "X-Plane 11", "xp11", {}, + {}, {}, {}, + "X-Plane.exe", "", + "", 0, + "", + "", "", "", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-X-Plane-11", + {}, {}, {}, {} + }, + // 75. Zeus and Poseidon + { + "Zeus and Poseidon Support Plugin", "Holt59", "1.0.0", + "Zeus and Poseidon", "zeusandposeidon", {}, + {566050}, {1207659039}, {}, + "Zeus.exe", "", + "", 0, + "Adventures", + "%GAME_PATH%", + "%GAME_PATH%/Save", "sav", + {}, + "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Zeus-Poseidon", + {}, {}, {}, {} + }, + }; + // clang-format on + + return defs; +} diff --git a/libs/basic_games_native/src/gamedefs.h b/libs/basic_games_native/src/gamedefs.h new file mode 100644 index 0000000..bda93eb --- /dev/null +++ b/libs/basic_games_native/src/gamedefs.h @@ -0,0 +1,61 @@ +#ifndef GAMEDEFS_H +#define GAMEDEFS_H + +#include <uibase/iplugingame.h> + +#include <QList> +#include <QString> +#include <QStringList> + +#include <vector> + +struct GameDefinition { + // Plugin metadata + QString pluginName; + QString author; + QString version; // parsed as VersionInfo string + + // Game identity + QString gameName; + QString gameShortName; + QStringList validShortNames; + + // Store IDs + QList<int> steamAppIds; + QList<int> gogAppIds; + QStringList epicAppIds; + + // Executables + QString binaryName; + QString launcherName; + + // Nexus + QString gameNexusName; + int nexusGameId = 0; + + // Directories (may contain %DOCUMENTS%, %USERPROFILE%, %GAME_PATH%, + // %GAME_DOCUMENTS%) + QString dataDirectory; + QString documentsDirectory; + QString savesDirectory; + QString saveExtension; + + // Configuration + QStringList iniFiles; + QString supportURL; + + // Load order / sorting + MOBase::IPluginGame::LoadOrderMechanism loadOrderMechanism = + MOBase::IPluginGame::LoadOrderMechanism::None; + MOBase::IPluginGame::SortMechanism sortMechanism = + MOBase::IPluginGame::SortMechanism::NONE; + + // Plugins + QStringList primaryPlugins; + QStringList dlcPlugins; +}; + +// Returns the complete list of all game definitions +const std::vector<GameDefinition>& allGameDefinitions(); + +#endif // GAMEDEFS_H diff --git a/libs/basic_games_native/src/steamutils.cpp b/libs/basic_games_native/src/steamutils.cpp new file mode 100644 index 0000000..28fac2b --- /dev/null +++ b/libs/basic_games_native/src/steamutils.cpp @@ -0,0 +1,156 @@ +#include "steamutils.h" + +#include <QDir> +#include <QFile> +#include <QRegularExpression> +#include <QStandardPaths> +#include <QTextStream> + +static QStringList findSteamPaths() +{ + QStringList paths; + QString home = QDir::homePath(); + + QStringList candidates = { + home + "/.local/share/Steam", + home + "/.steam/debian-installation", + home + "/.steam/steam", + home + "/.var/app/com.valvesoftware.Steam/data/Steam", + home + "/.var/app/com.valvesoftware.Steam/.local/share/Steam", + home + "/snap/steam/common/.local/share/Steam", + }; + + for (const auto& candidate : candidates) { + QDir dir(candidate); + if (dir.exists("steamapps") || QFile::exists(candidate + "/steam.pid")) { + // Resolve symlinks to avoid duplicates + QString canonical = QFileInfo(candidate).canonicalFilePath(); + if (!canonical.isEmpty() && !paths.contains(canonical)) { + paths.append(canonical); + } + } + } + + return paths; +} + +static QStringList parseLibraryFolders(const QString& vdfPath) +{ + QStringList folders; + QFile file(vdfPath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return folders; + + QTextStream in(&file); + QString content = in.readAll(); + + // Match "path" entries in VDF format: "path" "/path/to/library" + static QRegularExpression re(R"--("path"\s+"([^"]+)")--"); + auto it = re.globalMatch(content); + while (it.hasNext()) { + auto match = it.next(); + QString path = match.captured(1); + if (QDir(path).exists()) { + folders.append(path); + } + } + + return folders; +} + +static QStringList getAllLibraryFolders(const QString& steamPath) +{ + QStringList folders; + folders.append(steamPath); + + // Parse libraryfolders.vdf + for (const auto& vdfName : + {"steamapps/libraryfolders.vdf", "config/libraryfolders.vdf"}) { + QString vdfPath = steamPath + "/" + vdfName; + for (const auto& path : parseLibraryFolders(vdfPath)) { + if (!folders.contains(path)) { + folders.append(path); + } + } + } + + return folders; +} + +struct AppManifest { + int appId = 0; + QString name; + QString installDir; + int stateFlags = 0; + + bool isInstalled() const { return (stateFlags & 4) != 0; } +}; + +static AppManifest parseAppManifest(const QString& path) +{ + AppManifest manifest; + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return manifest; + + QTextStream in(&file); + QString content = in.readAll(); + + static QRegularExpression reAppId(R"--("appid"\s+"(\d+)")--"); + static QRegularExpression reName(R"--("name"\s+"([^"]+)")--"); + static QRegularExpression reInstallDir(R"--("installdir"\s+"([^"]+)")--"); + static QRegularExpression reState(R"--("StateFlags"\s+"(\d+)")--"); + + auto m = reAppId.match(content); + if (m.hasMatch()) + manifest.appId = m.captured(1).toInt(); + + m = reName.match(content); + if (m.hasMatch()) + manifest.name = m.captured(1); + + m = reInstallDir.match(content); + if (m.hasMatch()) + manifest.installDir = m.captured(1); + + m = reState.match(content); + if (m.hasMatch()) + manifest.stateFlags = m.captured(1).toInt(); + + return manifest; +} + +QHash<int, QString> findSteamGames() +{ + QHash<int, QString> games; + + for (const auto& steamPath : findSteamPaths()) { + for (const auto& libraryPath : getAllLibraryFolders(steamPath)) { + QDir steamapps(libraryPath + "/steamapps"); + if (!steamapps.exists()) + continue; + + QStringList manifests = + steamapps.entryList({"appmanifest_*.acf"}, QDir::Files); + for (const auto& manifestFile : manifests) { + auto manifest = parseAppManifest(steamapps.filePath(manifestFile)); + if (manifest.appId > 0 && manifest.isInstalled() && + !manifest.installDir.isEmpty()) { + QString installPath = + steamapps.filePath("common/" + manifest.installDir); + if (QDir(installPath).exists()) { + games.insert(manifest.appId, installPath); + } + } + } + } + } + + return games; +} + +QString findSteamGamePath(int appId) +{ + auto games = findSteamGames(); + return games.value(appId); +} diff --git a/libs/basic_games_native/src/steamutils.h b/libs/basic_games_native/src/steamutils.h new file mode 100644 index 0000000..5ef11b3 --- /dev/null +++ b/libs/basic_games_native/src/steamutils.h @@ -0,0 +1,14 @@ +#ifndef STEAMUTILS_H +#define STEAMUTILS_H + +#include <QHash> +#include <QString> + +// Scans all Steam library folders and returns a map of +// Steam App ID -> installation path +QHash<int, QString> findSteamGames(); + +// Find the installation path for a specific Steam app ID +QString findSteamGamePath(int appId); + +#endif // STEAMUTILS_H diff --git a/libs/form43_checker_native/CMakeLists.txt b/libs/form43_checker_native/CMakeLists.txt new file mode 100644 index 0000000..e0f1d08 --- /dev/null +++ b/libs/form43_checker_native/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.16) + +project(form43_checker_native) + +add_subdirectory(src) diff --git a/libs/form43_checker_native/src/CMakeLists.txt b/libs/form43_checker_native/src/CMakeLists.txt new file mode 100644 index 0000000..9e847f6 --- /dev/null +++ b/libs/form43_checker_native/src/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.16) + +file(GLOB form43_checker_native_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h +) + +add_library(form43_checker_native SHARED ${form43_checker_native_SOURCES}) +mo2_configure_plugin(form43_checker_native NO_SOURCES WARNINGS OFF) +target_link_libraries(form43_checker_native PRIVATE mo2::uibase) +mo2_install_plugin(form43_checker_native) diff --git a/libs/form43_checker_native/src/form43checker.cpp b/libs/form43_checker_native/src/form43checker.cpp new file mode 100644 index 0000000..e3cb676 --- /dev/null +++ b/libs/form43_checker_native/src/form43checker.cpp @@ -0,0 +1,131 @@ +#include "form43checker.h" + +#include <uibase/ipluginlist.h> +#include <uibase/pluginrequirements.h> + +#include <QCoreApplication> +#include <QFileInfo> + +using namespace MOBase; + +Form43Checker::Form43Checker() : m_organizer(nullptr) {} + +bool Form43Checker::init(IOrganizer* moInfo) +{ + m_organizer = moInfo; + return true; +} + +QString Form43Checker::name() const +{ + return "Form 43 Plugin Checker (Native)"; +} + +QString Form43Checker::localizedName() const +{ + return tr("Form 43 Plugin Checker (Native)"); +} + +QString Form43Checker::author() const +{ + return "AnyOldName3"; +} + +QString Form43Checker::description() const +{ + return tr("Checks plugins (.ESM/.ESP files) to see if any are lower than " + "Form 44 (Skyrim SE)."); +} + +VersionInfo Form43Checker::version() const +{ + return VersionInfo(1, 2, 0, VersionInfo::RELEASE_FINAL); +} + +std::vector<std::shared_ptr<const IPluginRequirement>> +Form43Checker::requirements() const +{ + return {PluginRequirementFactory::gameDependency("Skyrim Special Edition")}; +} + +QList<PluginSetting> Form43Checker::settings() const +{ + return {}; +} + +std::vector<unsigned int> Form43Checker::activeProblems() const +{ + updateInvalidPlugins(); + if (!m_invalidPlugins.isEmpty()) { + return {PROBLEM_FORM43}; + } + return {}; +} + +QString Form43Checker::shortDescription(unsigned int key) const +{ + return tr("Form 43 (or lower) plugin detected"); +} + +QString Form43Checker::fullDescription(unsigned int key) const +{ + QStringList plugins = listPlugins(); + QString pluginListString = + "<br><br>\u2022 " + plugins.join("<br>\u2022 "); + + QString output = tr("You have one or more plugins that are not form 44. " + "They are:%1") + .arg(pluginListString); + output += "<br><br>"; + output += tr( + "Form 43 (or lower) plugins are modules that were made for Skyrim LE " + "(Oldrim) and have not been properly ported to Skyrim Special Edition, " + "which uses form 44 plugins. This usually results in parts of the mod " + "not working correctly." + "<br><br>" + "To be converted, these plugins simply need to be opened and saved with " + "the SSE Creation Kit but their presence can be an indication that a mod " + "was not properly ported to SSE and so can potentially have additional " + "issues." + "<br><br>" + "Online guides can have more information on how to correctly convert mods " + "for Skyrim SE.<br>"); + return output; +} + +bool Form43Checker::hasGuidedFix(unsigned int key) const +{ + return false; +} + +void Form43Checker::startGuidedFix(unsigned int key) const {} + +void Form43Checker::updateInvalidPlugins() const +{ + m_invalidPlugins.clear(); + QStringList files = m_organizer->findFiles( + "", QStringList() << "*.esp" << "*.esm"); + for (const QString& file : files) { + int ver = getFormVersion(file); + if (ver != -1 && ver < 44) { + m_invalidPlugins.append(file); + } + } +} + +int Form43Checker::getFormVersion(const QString& file) const +{ + QString pluginName = QFileInfo(file).fileName(); + return m_organizer->pluginList()->formVersion(pluginName); +} + +QStringList Form43Checker::listPlugins() const +{ + QStringList result; + for (const QString& file : m_invalidPlugins) { + QString pluginName = QFileInfo(file).fileName(); + int ver = getFormVersion(file); + result.append(QString("%1 (form %2)").arg(pluginName).arg(ver)); + } + return result; +} diff --git a/libs/form43_checker_native/src/form43checker.h b/libs/form43_checker_native/src/form43checker.h new file mode 100644 index 0000000..583c7fa --- /dev/null +++ b/libs/form43_checker_native/src/form43checker.h @@ -0,0 +1,53 @@ +#ifndef FORM43CHECKER_H +#define FORM43CHECKER_H + +#include <memory> + +#include <QString> +#include <QStringList> + +#include <uibase/iplugin.h> +#include <uibase/iplugindiagnose.h> +#include <uibase/pluginrequirements.h> + +class Form43Checker : public QObject, + public MOBase::IPlugin, + public MOBase::IPluginDiagnose +{ + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginDiagnose) + Q_PLUGIN_METADATA(IID "org.tannin.Form43CheckerNative") + +public: + Form43Checker(); + +public: // IPlugin + bool init(MOBase::IOrganizer* moInfo) override; + QString name() const override; + QString localizedName() const override; + QString author() const override; + QString description() const override; + MOBase::VersionInfo version() const override; + std::vector<std::shared_ptr<const MOBase::IPluginRequirement>> + requirements() const override; + QList<MOBase::PluginSetting> settings() const override; + +public: // IPluginDiagnose + std::vector<unsigned int> activeProblems() const override; + QString shortDescription(unsigned int key) const override; + QString fullDescription(unsigned int key) const override; + bool hasGuidedFix(unsigned int key) const override; + void startGuidedFix(unsigned int key) const override; + +private: + static const unsigned int PROBLEM_FORM43 = 0; + + void updateInvalidPlugins() const; + int getFormVersion(const QString& file) const; + QStringList listPlugins() const; + + MOBase::IOrganizer* m_organizer; + mutable QStringList m_invalidPlugins; +}; + +#endif // FORM43CHECKER_H diff --git a/libs/plugin_python/src/runner/pythonrunner.cpp b/libs/plugin_python/src/runner/pythonrunner.cpp index 3bc62a1..0e6d596 100644 --- a/libs/plugin_python/src/runner/pythonrunner.cpp +++ b/libs/plugin_python/src/runner/pythonrunner.cpp @@ -147,20 +147,63 @@ namespace mo2::python { } #endif - // For portable/AppImage builds, set PYTHONHOME so the interpreter - // finds the bundled stdlib instead of looking at system paths. - // MO2_PYTHON_DIR (set by AppRun) points to the writable python/ - // dir next to the AppImage; fall back to <exe_dir>/python. + // Determine Python home directory. + // Priority: 1) venv at ~/.local/share/fluorine/python-venv/ + // 2) MO2_PYTHON_DIR env var (legacy bundled Python) + // 3) <exe_dir>/python (legacy bundled Python) + // 4) system Python (no PYTHONHOME needed) QString pythonHome; - const char* envPy = std::getenv("MO2_PYTHON_DIR"); - if (envPy && envPy[0] != '\0') { - pythonHome = QString::fromUtf8(envPy); - } else { - pythonHome = QCoreApplication::applicationDirPath() + "/python"; + QString venvPath; + bool usingVenv = false; + + // Check for user-created venv + { + QString dataDir = QDir::homePath() + "/.local/share/fluorine"; + QString venvDir = dataDir + "/python-venv"; + if (QDir(venvDir).exists() && QFile::exists(venvDir + "/bin/python3")) { + venvPath = venvDir; + // Read pyvenv.cfg to find the base Python prefix + QFile cfg(venvDir + "/pyvenv.cfg"); + if (cfg.open(QIODevice::ReadOnly | QIODevice::Text)) { + while (!cfg.atEnd()) { + QString line = QString::fromUtf8(cfg.readLine()).trimmed(); + if (line.startsWith("home")) { + // "home = /usr/bin" → base prefix is parent of bin dir + int eq = line.indexOf('='); + if (eq >= 0) { + QString binDir = line.mid(eq + 1).trimmed(); + QDir parent(binDir); + parent.cdUp(); + pythonHome = parent.absolutePath(); + usingVenv = true; + MOBase::log::info("python: using venv at '{}', base prefix '{}'", + venvPath, pythonHome); + } + break; + } + } + } + } } - if (!QDir(pythonHome).exists()) { - MOBase::log::warn("python: PYTHONHOME dir '{}' does not exist", - pythonHome); + + // Fallback: legacy bundled Python + if (pythonHome.isEmpty()) { + const char* envPy = std::getenv("MO2_PYTHON_DIR"); + if (envPy && envPy[0] != '\0') { + pythonHome = QString::fromUtf8(envPy); + } else { + QString bundled = QCoreApplication::applicationDirPath() + "/python"; + if (QDir(bundled).exists()) { + pythonHome = bundled; + } + } + } + + // If no bundled Python, use system Python (don't set PYTHONHOME) + if (!pythonHome.isEmpty() && !QDir(pythonHome).exists()) { + MOBase::log::warn("python: PYTHONHOME dir '{}' does not exist, " + "falling back to system Python", pythonHome); + pythonHome.clear(); } std::optional<QByteArray> oldPythonHome; @@ -187,66 +230,80 @@ namespace mo2::python { // Paths we want to prepend/append for MO2 plugin loading. auto paths = pythonPaths; - // Configure bundled Python like running python/bin/python3 directly. - const QDir libDir(pythonHome + "/lib"); - const auto pyDirs = - libDir.entryList({"python3.*"}, QDir::Dirs | QDir::NoDotAndDotDot); - const QString pyverDir = pyDirs.isEmpty() ? QStringLiteral("python3.13") - : pyDirs.first(); - const QString stdlibDir = pythonHome + "/lib/" + pyverDir; - const QString stdlibZip = pythonHome + "/lib/python313.zip"; - const QString rootDynloadDir = pythonHome + "/lib-dynload"; - const QString rootPlatDir = pythonHome + "/plat-linux2"; - const QString dynloadDir = stdlibDir + "/lib-dynload"; - const QString siteDir = stdlibDir + "/site-packages"; + // Build PYTHONPATH and optionally set PYTHONHOME. + QStringList corePaths; - QStringList corePaths = {stdlibDir, siteDir, dynloadDir}; - if (QFile::exists(stdlibZip)) { - corePaths.prepend(stdlibZip); - } - if (QDir(rootDynloadDir).exists()) { - corePaths.append(rootDynloadDir); + if (!pythonHome.isEmpty()) { + // Bundled or system Python with known prefix. + const QDir libDir(pythonHome + "/lib"); + const auto pyDirs = + libDir.entryList({"python3.*"}, QDir::Dirs | QDir::NoDotAndDotDot); + const QString pyverDir = pyDirs.isEmpty() ? QStringLiteral("python3.13") + : pyDirs.first(); + const QString stdlibDir = pythonHome + "/lib/" + pyverDir; + const QString dynloadDir = stdlibDir + "/lib-dynload"; + const QString siteDir = stdlibDir + "/site-packages"; + + corePaths = {stdlibDir, siteDir, dynloadDir}; + + const QString stdlibZip = pythonHome + "/lib/python313.zip"; + if (QFile::exists(stdlibZip)) { + corePaths.prepend(stdlibZip); + } + const QString rootDynloadDir = pythonHome + "/lib-dynload"; + if (QDir(rootDynloadDir).exists()) { + corePaths.append(rootDynloadDir); + } + + corePaths.append(pythonHome); + setenv("PYTHONHOME", pythonHome.toUtf8().constData(), 1); } - if (QDir(rootPlatDir).exists()) { - corePaths.append(rootPlatDir); + + // If using a venv, prepend its site-packages so venv packages + // (PyQt6, etc.) take priority over system site-packages. + if (usingVenv && !venvPath.isEmpty()) { + const QDir venvLibDir(venvPath + "/lib"); + const auto venvPyDirs = + venvLibDir.entryList({"python3.*"}, QDir::Dirs | QDir::NoDotAndDotDot); + if (!venvPyDirs.isEmpty()) { + QString venvSite = venvPath + "/lib/" + venvPyDirs.first() + "/site-packages"; + if (QDir(venvSite).exists()) { + corePaths.prepend(venvSite); + } + } } - corePaths.append(pythonHome); - const QByteArray pyPath = corePaths.join(":").toUtf8(); - setenv("PYTHONHOME", pythonHome.toUtf8().constData(), 1); - setenv("PYTHONPATH", pyPath.constData(), 1); + if (!corePaths.isEmpty()) { + setenv("PYTHONPATH", corePaths.join(":").toUtf8().constData(), 1); + } - fprintf(stderr, - "[py-diag] calling Py_InitializeFromConfig, PYTHONHOME='%s', " - "Py_IsInitialized before=%d\n", - pythonHome.toUtf8().constData(), Py_IsInitialized()); MOBase::log::debug( "python: calling Py_InitializeFromConfig, PYTHONHOME='{}', " - "Py_IsInitialized before={}", - pythonHome, Py_IsInitialized()); + "venv='{}', Py_IsInitialized before={}", + pythonHome.isEmpty() ? "(system)" : pythonHome, + venvPath.isEmpty() ? "(none)" : venvPath, + Py_IsInitialized()); // Use Py_InitializeFromConfig (Python 3.8+) for explicit error reporting. { PyConfig config; PyConfig_InitPythonConfig(&config); - // Set config.home directly (more reliable than env for embedded use). - std::wstring wHome = pythonHome.toStdWString(); - PyStatus status = PyConfig_SetString(&config, &config.home, wHome.c_str()); - if (PyStatus_Exception(status)) { - MOBase::log::error( - "python: PyConfig_SetString(home) failed: '{}'", - status.err_msg ? status.err_msg : "(no message)"); - PyConfig_Clear(&config); - restorePythonEnv(); - return false; + if (!pythonHome.isEmpty()) { + // Set config.home directly (more reliable than env for embedded use). + std::wstring wHome = pythonHome.toStdWString(); + PyStatus status = PyConfig_SetString(&config, &config.home, wHome.c_str()); + if (PyStatus_Exception(status)) { + MOBase::log::error( + "python: PyConfig_SetString(home) failed: '{}'", + status.err_msg ? status.err_msg : "(no message)"); + PyConfig_Clear(&config); + restorePythonEnv(); + return false; + } } - status = Py_InitializeFromConfig(&config); + PyStatus status = Py_InitializeFromConfig(&config); PyConfig_Clear(&config); if (PyStatus_Exception(status)) { - fprintf(stderr, - "[py-diag] Py_InitializeFromConfig FAILED: '%s' [in '%s']\n", - status.err_msg ? status.err_msg : "(no message)", - status.func ? status.func : "(no func)"); MOBase::log::error( "python: Py_InitializeFromConfig failed: '{}' [in '{}']", status.err_msg ? status.err_msg : "(no message)", @@ -256,23 +313,12 @@ namespace mo2::python { } } - fprintf(stderr, "[py-diag] Py_IsInitialized after=%d\n", - Py_IsInitialized()); MOBase::log::debug("python: Py_IsInitialized after={}", Py_IsInitialized()); if (!Py_IsInitialized()) { - const char* ph = std::getenv("PYTHONHOME"); - const char* pp = std::getenv("PYTHONPATH"); MOBase::log::error( "failed to init python: Py_IsInitialized() returned false."); - MOBase::log::error(" PYTHONHOME='{}', PYTHONPATH='{}'", - ph ? ph : "(null)", pp ? pp : "(null)"); - MOBase::log::error(" pythonHome='{}', exists={}", pythonHome, - QDir(pythonHome).exists() ? "yes" : "no"); - const QString encPath = pythonHome + "/lib/python3.13/encodings"; - MOBase::log::error(" encodings dir='{}', exists={}", encPath, - QDir(encPath).exists() ? "yes" : "no"); restorePythonEnv(); return false; } diff --git a/libs/preview_dds_native/CMakeLists.txt b/libs/preview_dds_native/CMakeLists.txt new file mode 100644 index 0000000..2e50105 --- /dev/null +++ b/libs/preview_dds_native/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.16) + +project(preview_dds_native) + +add_subdirectory(src) diff --git a/libs/preview_dds_native/src/CMakeLists.txt b/libs/preview_dds_native/src/CMakeLists.txt new file mode 100644 index 0000000..c920d49 --- /dev/null +++ b/libs/preview_dds_native/src/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.16) + +find_package(Qt6 REQUIRED COMPONENTS OpenGL OpenGLWidgets) + +file(GLOB preview_dds_native_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h +) + +add_library(preview_dds_native SHARED ${preview_dds_native_SOURCES}) +mo2_configure_plugin(preview_dds_native NO_SOURCES WARNINGS OFF) +target_link_libraries(preview_dds_native PRIVATE + mo2::uibase + Qt6::OpenGL + Qt6::OpenGLWidgets +) +mo2_install_plugin(preview_dds_native) diff --git a/libs/preview_dds_native/src/ddsfile.cpp b/libs/preview_dds_native/src/ddsfile.cpp new file mode 100644 index 0000000..91dc627 --- /dev/null +++ b/libs/preview_dds_native/src/ddsfile.cpp @@ -0,0 +1,132 @@ +#include "ddsfile.h" + +#include <QFile> + +#include <algorithm> +#include <cstring> + +bool DDSFile::loadFromFile(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + return parse(file.readAll()); +} + +bool DDSFile::loadFromData(const QByteArray& data) +{ + return parse(data); +} + +bool DDSFile::parse(const QByteArray& data) +{ + const char* ptr = data.constData(); + int offset = 0; + int size = data.size(); + + // Magic number + if (size < 4) + return false; + uint32_t magic; + std::memcpy(&magic, ptr + offset, 4); + offset += 4; + if (magic != DDS_MAGIC) + return false; + + // DDS header + if (size - offset < static_cast<int>(sizeof(DDSHeader))) + return false; + DDSHeader header; + std::memcpy(&header, ptr + offset, sizeof(DDSHeader)); + offset += sizeof(DDSHeader); + + if (header.dwSize != 124) + return false; + + // DXT10 extended header + DDSHeaderDXT10 dxt10{}; + bool hasDXT10 = false; + if ((header.ddspf.dwFlags & DDPF_FOURCC) && + header.ddspf.dwFourCC == makeFourCC('D', 'X', '1', '0')) { + if (size - offset < static_cast<int>(sizeof(DDSHeaderDXT10))) + return false; + std::memcpy(&dxt10, ptr + offset, sizeof(DDSHeaderDXT10)); + offset += sizeof(DDSHeaderDXT10); + hasDXT10 = true; + } + + m_width = header.dwWidth; + m_height = header.dwHeight; + + // Determine GL format + m_glFormat = getGLFormat(header.ddspf, hasDXT10 ? &dxt10 : nullptr); + if (!m_glFormat.valid) { + return false; + } + + // Determine DXGI format for size calculations + if (hasDXT10) { + m_dxgiFormat = dxt10.dxgiFormat; + } else if (header.ddspf.dwFlags & DDPF_FOURCC) { + m_dxgiFormat = fourCCToDXGI(header.ddspf.dwFourCC); + } else { + m_dxgiFormat = DXGIFormat::UNKNOWN; + } + + // Description + m_description = formatDescription(header.ddspf, hasDXT10 ? &dxt10 : nullptr); + m_description += + QString(" | %1x%2").arg(m_width).arg(m_height); + + // Cubemap detection + m_cubemap = false; + int layers = 1; + if (header.dwCaps2 & DDSCAPS2_CUBEMAP) { + m_cubemap = true; + layers = 0; + if (header.dwCaps2 & DDSCAPS2_CUBEMAP_POSITIVEX) layers++; + if (header.dwCaps2 & DDSCAPS2_CUBEMAP_NEGATIVEX) layers++; + if (header.dwCaps2 & DDSCAPS2_CUBEMAP_POSITIVEY) layers++; + if (header.dwCaps2 & DDSCAPS2_CUBEMAP_NEGATIVEY) layers++; + if (header.dwCaps2 & DDSCAPS2_CUBEMAP_POSITIVEZ) layers++; + if (header.dwCaps2 & DDSCAPS2_CUBEMAP_NEGATIVEZ) layers++; + m_description += " | Cubemap"; + } else { + m_description += " | 2D"; + } + + // Mipmap count + int mipCount = 1; + if (header.dwFlags & DDSD_MIPMAPCOUNT) { + mipCount = std::max(1u, header.dwMipMapCount); + } + if (mipCount > 1) { + m_description += QString(" | %1 mips").arg(mipCount); + } + + // Read pixel data for each face and mip level + m_faces.resize(layers); + for (int face = 0; face < layers; ++face) { + m_faces[face].mips.resize(mipCount); + uint32_t w = m_width; + uint32_t h = m_height; + for (int mip = 0; mip < mipCount; ++mip) { + uint32_t dataSize = mipDataSize(m_dxgiFormat, header.ddspf, w, h); + if (offset + static_cast<int>(dataSize) > size) { + // Truncated file, keep what we have + m_faces[face].mips.resize(mip); + break; + } + m_faces[face].mips[mip].width = w; + m_faces[face].mips[mip].height = h; + m_faces[face].mips[mip].data = + QByteArray(ptr + offset, static_cast<int>(dataSize)); + offset += dataSize; + w = std::max(1u, w / 2); + h = std::max(1u, h / 2); + } + } + + return !m_faces.isEmpty() && !m_faces[0].mips.isEmpty(); +} diff --git a/libs/preview_dds_native/src/ddsfile.h b/libs/preview_dds_native/src/ddsfile.h new file mode 100644 index 0000000..4242c61 --- /dev/null +++ b/libs/preview_dds_native/src/ddsfile.h @@ -0,0 +1,47 @@ +#ifndef DDSFILE_H +#define DDSFILE_H + +#include "ddsformat.h" + +#include <QByteArray> +#include <QString> +#include <QVector> + +struct DDSMipLevel { + uint32_t width; + uint32_t height; + QByteArray data; +}; + +struct DDSFace { + QVector<DDSMipLevel> mips; +}; + +class DDSFile +{ +public: + bool loadFromFile(const QString& path); + bool loadFromData(const QByteArray& data); + + uint32_t width() const { return m_width; } + uint32_t height() const { return m_height; } + bool isCubemap() const { return m_cubemap; } + int faceCount() const { return m_faces.size(); } + int mipCount() const { return m_faces.isEmpty() ? 0 : m_faces[0].mips.size(); } + const DDSFace& face(int i) const { return m_faces[i]; } + const GLFormatInfo& glFormat() const { return m_glFormat; } + QString description() const { return m_description; } + +private: + bool parse(const QByteArray& data); + + uint32_t m_width = 0; + uint32_t m_height = 0; + bool m_cubemap = false; + GLFormatInfo m_glFormat; + DXGIFormat m_dxgiFormat = DXGIFormat::UNKNOWN; + QString m_description; + QVector<DDSFace> m_faces; +}; + +#endif // DDSFILE_H diff --git a/libs/preview_dds_native/src/ddsformat.cpp b/libs/preview_dds_native/src/ddsformat.cpp new file mode 100644 index 0000000..b56db91 --- /dev/null +++ b/libs/preview_dds_native/src/ddsformat.cpp @@ -0,0 +1,619 @@ +#include "ddsformat.h" + +#include <QOpenGLFunctions> +#include <QString> + +#include <algorithm> +#include <cstring> + +// GL compressed format constants not always in headers +#ifndef GL_COMPRESSED_RGB_S3TC_DXT1_EXT +#define GL_COMPRESSED_RGB_S3TC_DXT1_EXT 0x83F0 +#endif +#ifndef GL_COMPRESSED_RGBA_S3TC_DXT1_EXT +#define GL_COMPRESSED_RGBA_S3TC_DXT1_EXT 0x83F1 +#endif +#ifndef GL_COMPRESSED_RGBA_S3TC_DXT3_EXT +#define GL_COMPRESSED_RGBA_S3TC_DXT3_EXT 0x83F2 +#endif +#ifndef GL_COMPRESSED_RGBA_S3TC_DXT5_EXT +#define GL_COMPRESSED_RGBA_S3TC_DXT5_EXT 0x83F3 +#endif +#ifndef GL_COMPRESSED_RED_RGTC1 +#define GL_COMPRESSED_RED_RGTC1 0x8DBB +#endif +#ifndef GL_COMPRESSED_SIGNED_RED_RGTC1 +#define GL_COMPRESSED_SIGNED_RED_RGTC1 0x8DBC +#endif +#ifndef GL_COMPRESSED_RG_RGTC2 +#define GL_COMPRESSED_RG_RGTC2 0x8DBD +#endif +#ifndef GL_COMPRESSED_SIGNED_RG_RGTC2 +#define GL_COMPRESSED_SIGNED_RG_RGTC2 0x8DBE +#endif +#ifndef GL_COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT +#define GL_COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT 0x8E8F +#endif +#ifndef GL_COMPRESSED_RGB_BPTC_SIGNED_FLOAT +#define GL_COMPRESSED_RGB_BPTC_SIGNED_FLOAT 0x8E8E +#endif +#ifndef GL_COMPRESSED_RGBA_BPTC_UNORM +#define GL_COMPRESSED_RGBA_BPTC_UNORM 0x8E8C +#endif +#ifndef GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM +#define GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM 0x8E8D +#endif +#ifndef GL_COMPRESSED_SRGB_S3TC_DXT1_EXT +#define GL_COMPRESSED_SRGB_S3TC_DXT1_EXT 0x8C4C +#endif +#ifndef GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT +#define GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT 0x8C4D +#endif +#ifndef GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT +#define GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT 0x8C4E +#endif +#ifndef GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT +#define GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT 0x8C4F +#endif + +static GLFormatInfo dxgiToGL(DXGIFormat fmt) +{ + GLFormatInfo info; + info.valid = true; + + switch (fmt) { + // BC1 (DXT1) + case DXGIFormat::BC1_UNORM: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_RGBA_S3TC_DXT1_EXT; + break; + case DXGIFormat::BC1_UNORM_SRGB: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT; + break; + // BC2 (DXT3) + case DXGIFormat::BC2_UNORM: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_RGBA_S3TC_DXT3_EXT; + break; + case DXGIFormat::BC2_UNORM_SRGB: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT; + break; + // BC3 (DXT5) + case DXGIFormat::BC3_UNORM: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_RGBA_S3TC_DXT5_EXT; + break; + case DXGIFormat::BC3_UNORM_SRGB: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT; + break; + // BC4 + case DXGIFormat::BC4_UNORM: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_RED_RGTC1; + break; + case DXGIFormat::BC4_SNORM: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_SIGNED_RED_RGTC1; + break; + // BC5 + case DXGIFormat::BC5_UNORM: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_RG_RGTC2; + break; + case DXGIFormat::BC5_SNORM: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_SIGNED_RG_RGTC2; + break; + // BC6H + case DXGIFormat::BC6H_UF16: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT; + break; + case DXGIFormat::BC6H_SF16: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_RGB_BPTC_SIGNED_FLOAT; + break; + // BC7 + case DXGIFormat::BC7_UNORM: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_RGBA_BPTC_UNORM; + break; + case DXGIFormat::BC7_UNORM_SRGB: + info.compressed = true; + info.internalFormat = GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM; + break; + + // Uncompressed RGBA + case DXGIFormat::R8G8B8A8_UNORM: + case DXGIFormat::R8G8B8A8_UNORM_SRGB: + info.internalFormat = GL_RGBA8; + info.format = GL_RGBA; + info.type = GL_UNSIGNED_BYTE; + break; + case DXGIFormat::R8G8B8A8_UINT: + info.internalFormat = GL_RGBA8UI; + info.format = GL_RGBA_INTEGER; + info.type = GL_UNSIGNED_BYTE; + info.sampler = SamplerType::UInt; + break; + case DXGIFormat::R8G8B8A8_SNORM: + info.internalFormat = GL_RGBA8_SNORM; + info.format = GL_RGBA; + info.type = GL_BYTE; + break; + case DXGIFormat::R8G8B8A8_SINT: + info.internalFormat = GL_RGBA8I; + info.format = GL_RGBA_INTEGER; + info.type = GL_BYTE; + info.sampler = SamplerType::SInt; + break; + + // BGRA + case DXGIFormat::B8G8R8A8_UNORM: + case DXGIFormat::B8G8R8A8_UNORM_SRGB: + info.internalFormat = GL_RGBA8; + info.format = GL_BGRA; + info.type = GL_UNSIGNED_BYTE; + break; + case DXGIFormat::B8G8R8X8_UNORM: + case DXGIFormat::B8G8R8X8_UNORM_SRGB: + info.internalFormat = GL_RGBA8; + info.format = GL_BGRA; + info.type = GL_UNSIGNED_BYTE; + break; + + // 16-bit float + case DXGIFormat::R16G16B16A16_FLOAT: + info.internalFormat = GL_RGBA16F; + info.format = GL_RGBA; + info.type = GL_HALF_FLOAT; + break; + case DXGIFormat::R16G16B16A16_UNORM: + info.internalFormat = GL_RGBA16; + info.format = GL_RGBA; + info.type = GL_UNSIGNED_SHORT; + break; + case DXGIFormat::R16G16B16A16_UINT: + info.internalFormat = GL_RGBA16UI; + info.format = GL_RGBA_INTEGER; + info.type = GL_UNSIGNED_SHORT; + info.sampler = SamplerType::UInt; + break; + case DXGIFormat::R16G16B16A16_SNORM: + info.internalFormat = GL_RGBA16_SNORM; + info.format = GL_RGBA; + info.type = GL_SHORT; + break; + case DXGIFormat::R16G16B16A16_SINT: + info.internalFormat = GL_RGBA16I; + info.format = GL_RGBA_INTEGER; + info.type = GL_SHORT; + info.sampler = SamplerType::SInt; + break; + + // 32-bit float + case DXGIFormat::R32G32B32A32_FLOAT: + info.internalFormat = GL_RGBA32F; + info.format = GL_RGBA; + info.type = GL_FLOAT; + break; + case DXGIFormat::R32G32B32_FLOAT: + info.internalFormat = GL_RGB32F; + info.format = GL_RGB; + info.type = GL_FLOAT; + break; + + // RG formats + case DXGIFormat::R8G8_UNORM: + info.internalFormat = GL_RG8; + info.format = GL_RG; + info.type = GL_UNSIGNED_BYTE; + break; + case DXGIFormat::R16G16_FLOAT: + info.internalFormat = GL_RG16F; + info.format = GL_RG; + info.type = GL_HALF_FLOAT; + break; + case DXGIFormat::R16G16_UNORM: + info.internalFormat = GL_RG16; + info.format = GL_RG; + info.type = GL_UNSIGNED_SHORT; + break; + case DXGIFormat::R32G32_FLOAT: + info.internalFormat = GL_RG32F; + info.format = GL_RG; + info.type = GL_FLOAT; + break; + + // R formats + case DXGIFormat::R8_UNORM: + info.internalFormat = GL_R8; + info.format = GL_RED; + info.type = GL_UNSIGNED_BYTE; + break; + case DXGIFormat::R16_FLOAT: + info.internalFormat = GL_R16F; + info.format = GL_RED; + info.type = GL_HALF_FLOAT; + break; + case DXGIFormat::R16_UNORM: + info.internalFormat = GL_R16; + info.format = GL_RED; + info.type = GL_UNSIGNED_SHORT; + break; + case DXGIFormat::R32_FLOAT: + info.internalFormat = GL_R32F; + info.format = GL_RED; + info.type = GL_FLOAT; + break; + case DXGIFormat::A8_UNORM: + info.internalFormat = GL_R8; + info.format = GL_RED; + info.type = GL_UNSIGNED_BYTE; + break; + + // Packed formats + case DXGIFormat::R10G10B10A2_UNORM: + info.internalFormat = GL_RGB10_A2; + info.format = GL_RGBA; + info.type = GL_UNSIGNED_INT_2_10_10_10_REV; + break; + case DXGIFormat::R11G11B10_FLOAT: + info.internalFormat = GL_R11F_G11F_B10F; + info.format = GL_RGB; + info.type = GL_UNSIGNED_INT_10F_11F_11F_REV; + break; + case DXGIFormat::B5G6R5_UNORM: + info.internalFormat = GL_RGB565; + info.format = GL_RGB; + info.type = GL_UNSIGNED_SHORT_5_6_5; + break; + case DXGIFormat::B5G5R5A1_UNORM: + info.internalFormat = GL_RGB5_A1; + info.format = GL_BGRA; + info.type = GL_UNSIGNED_SHORT_1_5_5_5_REV; + break; + case DXGIFormat::B4G4R4A4_UNORM: + info.internalFormat = GL_RGBA4; + info.format = GL_BGRA; + info.type = GL_UNSIGNED_SHORT_4_4_4_4_REV; + break; + + default: + info.valid = false; + break; + } + + return info; +} + +DXGIFormat fourCCToDXGI(uint32_t fourCC) +{ + if (fourCC == makeFourCC('D', 'X', 'T', '1')) + return DXGIFormat::BC1_UNORM; + if (fourCC == makeFourCC('D', 'X', 'T', '3')) + return DXGIFormat::BC2_UNORM; + if (fourCC == makeFourCC('D', 'X', 'T', '5')) + return DXGIFormat::BC3_UNORM; + if (fourCC == makeFourCC('B', 'C', '4', 'U') || + fourCC == makeFourCC('A', 'T', 'I', '1')) + return DXGIFormat::BC4_UNORM; + if (fourCC == makeFourCC('B', 'C', '4', 'S')) + return DXGIFormat::BC4_SNORM; + if (fourCC == makeFourCC('A', 'T', 'I', '2') || + fourCC == makeFourCC('B', 'C', '5', 'U')) + return DXGIFormat::BC5_UNORM; + if (fourCC == makeFourCC('B', 'C', '5', 'S')) + return DXGIFormat::BC5_SNORM; + + // Numeric FourCC codes for float/half-float formats + switch (fourCC) { + case 36: + return DXGIFormat::R16G16B16A16_UNORM; + case 110: + return DXGIFormat::R16G16B16A16_SNORM; + case 111: + return DXGIFormat::R16_FLOAT; + case 112: + return DXGIFormat::R16G16_FLOAT; + case 113: + return DXGIFormat::R16G16B16A16_FLOAT; + case 114: + return DXGIFormat::R32_FLOAT; + case 115: + return DXGIFormat::R32G32_FLOAT; + case 116: + return DXGIFormat::R32G32B32A32_FLOAT; + default: + return DXGIFormat::UNKNOWN; + } +} + +// Helper: count trailing zeros / bit shift for a mask +static int maskShift(uint32_t mask) +{ + if (mask == 0) + return 0; + int shift = 0; + while ((mask & 1) == 0) { + mask >>= 1; + ++shift; + } + return shift; +} + +static int maskBits(uint32_t mask) +{ + int count = 0; + while (mask) { + count += mask & 1; + mask >>= 1; + } + return count; +} + +// Build a converter for arbitrary bitmask pixel formats +static GLFormatInfo buildBitmaskFormat(const DDSPixelFormat& pf) +{ + GLFormatInfo info; + info.valid = true; + + int rShift = maskShift(pf.dwRBitMask); + int gShift = maskShift(pf.dwGBitMask); + int bShift = maskShift(pf.dwBBitMask); + int aShift = maskShift(pf.dwABitMask); + int rBits = maskBits(pf.dwRBitMask); + int gBits = maskBits(pf.dwGBitMask); + int bBits = maskBits(pf.dwBBitMask); + int aBits = maskBits(pf.dwABitMask); + int bpp = pf.dwRGBBitCount; + + // Try to match common uncompressed formats directly + if (bpp == 32 && pf.dwRBitMask == 0x000000FF && pf.dwGBitMask == 0x0000FF00 && + pf.dwBBitMask == 0x00FF0000 && + (pf.dwABitMask == 0xFF000000 || pf.dwABitMask == 0)) { + info.internalFormat = GL_RGBA8; + info.format = GL_RGBA; + info.type = GL_UNSIGNED_BYTE; + return info; + } + if (bpp == 32 && pf.dwRBitMask == 0x00FF0000 && pf.dwGBitMask == 0x0000FF00 && + pf.dwBBitMask == 0x000000FF) { + info.internalFormat = GL_RGBA8; + info.format = GL_BGRA; + info.type = GL_UNSIGNED_BYTE; + return info; + } + + // Generic converter: extract channels and pack into RGBA8 + uint32_t rMask = pf.dwRBitMask; + uint32_t gMask = pf.dwGBitMask; + uint32_t bMask = pf.dwBBitMask; + uint32_t aMask = pf.dwABitMask; + bool hasAlpha = (pf.dwFlags & DDPF_ALPHAPIXELS) && aMask != 0; + int bytesPerPixel = bpp / 8; + + info.internalFormat = GL_RGBA8; + info.format = GL_RGBA; + info.type = GL_UNSIGNED_BYTE; + info.converter = [=](const QByteArray& data, int w, int h) -> QByteArray { + QByteArray result(w * h * 4, '\0'); + const uint8_t* src = reinterpret_cast<const uint8_t*>(data.constData()); + uint8_t* dst = reinterpret_cast<uint8_t*>(result.data()); + + for (int i = 0; i < w * h; ++i) { + uint32_t pixel = 0; + std::memcpy(&pixel, src + i * bytesPerPixel, + std::min(bytesPerPixel, 4)); + + int r = rBits > 0 ? ((pixel & rMask) >> rShift) * 255 / ((1 << rBits) - 1) : 0; + int g = gBits > 0 ? ((pixel & gMask) >> gShift) * 255 / ((1 << gBits) - 1) : 0; + int b = bBits > 0 ? ((pixel & bMask) >> bShift) * 255 / ((1 << bBits) - 1) : 0; + int a = hasAlpha && aBits > 0 + ? ((pixel & aMask) >> aShift) * 255 / ((1 << aBits) - 1) + : 255; + + dst[i * 4 + 0] = static_cast<uint8_t>(r); + dst[i * 4 + 1] = static_cast<uint8_t>(g); + dst[i * 4 + 2] = static_cast<uint8_t>(b); + dst[i * 4 + 3] = static_cast<uint8_t>(a); + } + return result; + }; + + return info; +} + +GLFormatInfo getGLFormat(const DDSPixelFormat& pf, const DDSHeaderDXT10* dxt10) +{ + // DX10 extended header takes priority + if (dxt10) { + return dxgiToGL(dxt10->dxgiFormat); + } + + // FourCC compressed or float formats + if (pf.dwFlags & DDPF_FOURCC) { + DXGIFormat dxgi = fourCCToDXGI(pf.dwFourCC); + if (dxgi != DXGIFormat::UNKNOWN) { + return dxgiToGL(dxgi); + } + GLFormatInfo info; + info.valid = false; + return info; + } + + // Uncompressed with bitmasks + if (pf.dwFlags & (DDPF_RGB | DDPF_LUMINANCE | DDPF_YUV | DDPF_ALPHA)) { + return buildBitmaskFormat(pf); + } + + GLFormatInfo info; + info.valid = false; + return info; +} + +static bool isBlockCompressed(DXGIFormat fmt) +{ + switch (fmt) { + case DXGIFormat::BC1_UNORM: + case DXGIFormat::BC1_UNORM_SRGB: + case DXGIFormat::BC2_UNORM: + case DXGIFormat::BC2_UNORM_SRGB: + case DXGIFormat::BC3_UNORM: + case DXGIFormat::BC3_UNORM_SRGB: + case DXGIFormat::BC4_UNORM: + case DXGIFormat::BC4_SNORM: + case DXGIFormat::BC5_UNORM: + case DXGIFormat::BC5_SNORM: + case DXGIFormat::BC6H_UF16: + case DXGIFormat::BC6H_SF16: + case DXGIFormat::BC7_UNORM: + case DXGIFormat::BC7_UNORM_SRGB: + return true; + default: + return false; + } +} + +static int blockSize(DXGIFormat fmt) +{ + switch (fmt) { + case DXGIFormat::BC1_UNORM: + case DXGIFormat::BC1_UNORM_SRGB: + case DXGIFormat::BC4_UNORM: + case DXGIFormat::BC4_SNORM: + return 8; + default: + return 16; + } +} + +uint32_t mipDataSize(DXGIFormat fmt, const DDSPixelFormat& pf, + uint32_t width, uint32_t height) +{ + if (isBlockCompressed(fmt)) { + uint32_t blocksW = std::max(1u, (width + 3) / 4); + uint32_t blocksH = std::max(1u, (height + 3) / 4); + return blocksW * blocksH * blockSize(fmt); + } + + // Uncompressed: use bits per pixel + uint32_t bpp = pf.dwRGBBitCount; + if (bpp == 0) { + // Estimate from DXGI format for non-bitmask formats + switch (fmt) { + case DXGIFormat::R32G32B32A32_FLOAT: + bpp = 128; + break; + case DXGIFormat::R32G32B32_FLOAT: + bpp = 96; + break; + case DXGIFormat::R16G16B16A16_FLOAT: + case DXGIFormat::R16G16B16A16_UNORM: + case DXGIFormat::R16G16B16A16_SNORM: + case DXGIFormat::R32G32_FLOAT: + bpp = 64; + break; + case DXGIFormat::R8G8B8A8_UNORM: + case DXGIFormat::R8G8B8A8_UNORM_SRGB: + case DXGIFormat::B8G8R8A8_UNORM: + case DXGIFormat::B8G8R8X8_UNORM: + case DXGIFormat::R16G16_FLOAT: + case DXGIFormat::R16G16_UNORM: + case DXGIFormat::R32_FLOAT: + case DXGIFormat::R10G10B10A2_UNORM: + case DXGIFormat::R11G11B10_FLOAT: + bpp = 32; + break; + case DXGIFormat::R8G8_UNORM: + case DXGIFormat::R16_FLOAT: + case DXGIFormat::R16_UNORM: + case DXGIFormat::B5G6R5_UNORM: + case DXGIFormat::B5G5R5A1_UNORM: + case DXGIFormat::B4G4R4A4_UNORM: + bpp = 16; + break; + case DXGIFormat::R8_UNORM: + case DXGIFormat::A8_UNORM: + bpp = 8; + break; + default: + bpp = 32; + break; + } + } + return width * height * bpp / 8; +} + +static const char* dxgiFormatName(DXGIFormat fmt) +{ + switch (fmt) { + case DXGIFormat::BC1_UNORM: + return "BC1_UNORM (DXT1)"; + case DXGIFormat::BC1_UNORM_SRGB: + return "BC1_UNORM_SRGB"; + case DXGIFormat::BC2_UNORM: + return "BC2_UNORM (DXT3)"; + case DXGIFormat::BC2_UNORM_SRGB: + return "BC2_UNORM_SRGB"; + case DXGIFormat::BC3_UNORM: + return "BC3_UNORM (DXT5)"; + case DXGIFormat::BC3_UNORM_SRGB: + return "BC3_UNORM_SRGB"; + case DXGIFormat::BC4_UNORM: + return "BC4_UNORM"; + case DXGIFormat::BC4_SNORM: + return "BC4_SNORM"; + case DXGIFormat::BC5_UNORM: + return "BC5_UNORM"; + case DXGIFormat::BC5_SNORM: + return "BC5_SNORM"; + case DXGIFormat::BC6H_UF16: + return "BC6H_UF16"; + case DXGIFormat::BC6H_SF16: + return "BC6H_SF16"; + case DXGIFormat::BC7_UNORM: + return "BC7_UNORM"; + case DXGIFormat::BC7_UNORM_SRGB: + return "BC7_UNORM_SRGB"; + case DXGIFormat::R8G8B8A8_UNORM: + return "R8G8B8A8_UNORM"; + case DXGIFormat::R8G8B8A8_UNORM_SRGB: + return "R8G8B8A8_UNORM_SRGB"; + case DXGIFormat::B8G8R8A8_UNORM: + return "B8G8R8A8_UNORM"; + case DXGIFormat::R16G16B16A16_FLOAT: + return "R16G16B16A16_FLOAT"; + case DXGIFormat::R32G32B32A32_FLOAT: + return "R32G32B32A32_FLOAT"; + default: + return "Unknown"; + } +} + +QString formatDescription(const DDSPixelFormat& pf, const DDSHeaderDXT10* dxt10) +{ + if (dxt10) { + return QString("DXGI: %1").arg(dxgiFormatName(dxt10->dxgiFormat)); + } + + if (pf.dwFlags & DDPF_FOURCC) { + char cc[5] = {0}; + std::memcpy(cc, &pf.dwFourCC, 4); + DXGIFormat dxgi = fourCCToDXGI(pf.dwFourCC); + if (dxgi != DXGIFormat::UNKNOWN) { + return QString("FourCC: %1 (%2)").arg(cc).arg(dxgiFormatName(dxgi)); + } + return QString("FourCC: %1").arg(cc); + } + + return QString("%1-bit R:0x%2 G:0x%3 B:0x%4 A:0x%5") + .arg(pf.dwRGBBitCount) + .arg(pf.dwRBitMask, 0, 16) + .arg(pf.dwGBitMask, 0, 16) + .arg(pf.dwBBitMask, 0, 16) + .arg(pf.dwABitMask, 0, 16); +} diff --git a/libs/preview_dds_native/src/ddsformat.h b/libs/preview_dds_native/src/ddsformat.h new file mode 100644 index 0000000..22d3565 --- /dev/null +++ b/libs/preview_dds_native/src/ddsformat.h @@ -0,0 +1,200 @@ +#ifndef DDSFORMAT_H +#define DDSFORMAT_H + +#include <cstdint> +#include <functional> +#include <vector> + +#include <QByteArray> + +// DDS file magic number +constexpr uint32_t DDS_MAGIC = 0x20534444; // "DDS " + +// DDS_PIXELFORMAT flags +constexpr uint32_t DDPF_ALPHAPIXELS = 0x1; +constexpr uint32_t DDPF_ALPHA = 0x2; +constexpr uint32_t DDPF_FOURCC = 0x4; +constexpr uint32_t DDPF_RGB = 0x40; +constexpr uint32_t DDPF_YUV = 0x200; +constexpr uint32_t DDPF_LUMINANCE = 0x20000; + +// DDS_HEADER flags +constexpr uint32_t DDSD_CAPS = 0x1; +constexpr uint32_t DDSD_HEIGHT = 0x2; +constexpr uint32_t DDSD_WIDTH = 0x4; +constexpr uint32_t DDSD_PITCH = 0x8; +constexpr uint32_t DDSD_PIXELFORMAT = 0x1000; +constexpr uint32_t DDSD_MIPMAPCOUNT = 0x20000; +constexpr uint32_t DDSD_LINEARSIZE = 0x80000; +constexpr uint32_t DDSD_DEPTH = 0x800000; + +// DDS_HEADER caps2 +constexpr uint32_t DDSCAPS2_CUBEMAP = 0x200; +constexpr uint32_t DDSCAPS2_CUBEMAP_POSITIVEX = 0x400; +constexpr uint32_t DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800; +constexpr uint32_t DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000; +constexpr uint32_t DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000; +constexpr uint32_t DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000; +constexpr uint32_t DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000; +constexpr uint32_t DDSCAPS2_VOLUME = 0x200000; + +constexpr uint32_t DDSCAPS2_CUBEMAP_ALLFACES = + DDSCAPS2_CUBEMAP_POSITIVEX | DDSCAPS2_CUBEMAP_NEGATIVEX | + DDSCAPS2_CUBEMAP_POSITIVEY | DDSCAPS2_CUBEMAP_NEGATIVEY | + DDSCAPS2_CUBEMAP_POSITIVEZ | DDSCAPS2_CUBEMAP_NEGATIVEZ; + +// DXGI formats (subset covering common DDS textures) +enum class DXGIFormat : uint32_t { + UNKNOWN = 0, + R32G32B32A32_FLOAT = 2, + R32G32B32A32_UINT = 3, + R32G32B32A32_SINT = 4, + R32G32B32_FLOAT = 6, + R32G32B32_UINT = 7, + R32G32B32_SINT = 8, + R16G16B16A16_FLOAT = 10, + R16G16B16A16_UNORM = 11, + R16G16B16A16_UINT = 12, + R16G16B16A16_SNORM = 13, + R16G16B16A16_SINT = 14, + R32G32_FLOAT = 16, + R32G32_UINT = 17, + R32G32_SINT = 18, + R10G10B10A2_UNORM = 24, + R10G10B10A2_UINT = 25, + R11G11B10_FLOAT = 26, + R8G8B8A8_UNORM = 28, + R8G8B8A8_UNORM_SRGB = 29, + R8G8B8A8_UINT = 30, + R8G8B8A8_SNORM = 31, + R8G8B8A8_SINT = 32, + R16G16_FLOAT = 34, + R16G16_UNORM = 35, + R16G16_UINT = 36, + R16G16_SNORM = 37, + R16G16_SINT = 38, + R32_FLOAT = 41, + R32_UINT = 42, + R32_SINT = 43, + R8G8_UNORM = 49, + R8G8_UINT = 50, + R8G8_SNORM = 51, + R8G8_SINT = 52, + R16_FLOAT = 54, + R16_UNORM = 56, + R16_UINT = 57, + R16_SNORM = 58, + R16_SINT = 59, + R8_UNORM = 61, + R8_UINT = 62, + R8_SNORM = 63, + R8_SINT = 64, + A8_UNORM = 65, + BC1_UNORM = 71, + BC1_UNORM_SRGB = 72, + BC2_UNORM = 74, + BC2_UNORM_SRGB = 75, + BC3_UNORM = 77, + BC3_UNORM_SRGB = 78, + BC4_UNORM = 80, + BC4_SNORM = 81, + BC5_UNORM = 83, + BC5_SNORM = 84, + B5G6R5_UNORM = 85, + B5G5R5A1_UNORM = 86, + B8G8R8A8_UNORM = 87, + B8G8R8X8_UNORM = 88, + B8G8R8A8_UNORM_SRGB = 91, + B8G8R8X8_UNORM_SRGB = 93, + BC6H_UF16 = 95, + BC6H_SF16 = 96, + BC7_UNORM = 98, + BC7_UNORM_SRGB = 99, + B4G4R4A4_UNORM = 115, +}; + +// D3D10 resource dimension +enum class D3D10ResourceDimension : uint32_t { + Unknown = 0, + Buffer = 1, + Texture1D = 2, + Texture2D = 3, + Texture3D = 4, +}; + +#pragma pack(push, 1) + +struct DDSPixelFormat { + uint32_t dwSize; + uint32_t dwFlags; + uint32_t dwFourCC; + uint32_t dwRGBBitCount; + uint32_t dwRBitMask; + uint32_t dwGBitMask; + uint32_t dwBBitMask; + uint32_t dwABitMask; +}; + +struct DDSHeader { + uint32_t dwSize; + uint32_t dwFlags; + uint32_t dwHeight; + uint32_t dwWidth; + uint32_t dwPitchOrLinearSize; + uint32_t dwDepth; + uint32_t dwMipMapCount; + uint32_t dwReserved1[11]; + DDSPixelFormat ddspf; + uint32_t dwCaps; + uint32_t dwCaps2; + uint32_t dwCaps3; + uint32_t dwCaps4; + uint32_t dwReserved2; +}; + +struct DDSHeaderDXT10 { + DXGIFormat dxgiFormat; + D3D10ResourceDimension resourceDimension; + uint32_t miscFlag; + uint32_t arraySize; + uint32_t miscFlags2; +}; + +#pragma pack(pop) + +// Sampler type for shader selection +enum class SamplerType { Float, UInt, SInt }; + +// OpenGL format info +struct GLFormatInfo { + bool valid = false; + bool compressed = false; + uint32_t internalFormat = 0; + uint32_t format = 0; // only for uncompressed + uint32_t type = 0; // only for uncompressed + SamplerType sampler = SamplerType::Float; + // Optional converter for non-standard bitmask formats + std::function<QByteArray(const QByteArray&, int, int)> converter; +}; + +// Inline helper: make a FourCC from 4 chars +constexpr uint32_t makeFourCC(char a, char b, char c, char d) +{ + return static_cast<uint32_t>(a) | (static_cast<uint32_t>(b) << 8) | + (static_cast<uint32_t>(c) << 16) | (static_cast<uint32_t>(d) << 24); +} + +// Convert a FourCC code to DXGI format +DXGIFormat fourCCToDXGI(uint32_t fourCC); + +// Resolve the OpenGL format from a DDS pixel format + optional DXT10 header +GLFormatInfo getGLFormat(const DDSPixelFormat& pf, const DDSHeaderDXT10* dxt10); + +// Calculate mip level data size in bytes +uint32_t mipDataSize(DXGIFormat fmt, const DDSPixelFormat& pf, + uint32_t width, uint32_t height); + +// Get a human-readable format description +QString formatDescription(const DDSPixelFormat& pf, const DDSHeaderDXT10* dxt10); + +#endif // DDSFORMAT_H diff --git a/libs/preview_dds_native/src/ddspreview.cpp b/libs/preview_dds_native/src/ddspreview.cpp new file mode 100644 index 0000000..2bb95be --- /dev/null +++ b/libs/preview_dds_native/src/ddspreview.cpp @@ -0,0 +1,126 @@ +#include "ddspreview.h" +#include "ddsfile.h" +#include "ddswidget.h" + +#include <QLabel> +#include <QVBoxLayout> +#include <QWidget> + +using namespace MOBase; + +DDSPreview::DDSPreview() {} + +bool DDSPreview::init(IOrganizer* moInfo) +{ + m_organizer = moInfo; + return true; +} + +QString DDSPreview::name() const +{ + return "DDS Preview (Native)"; +} + +QString DDSPreview::localizedName() const +{ + return tr("DDS Preview (Native)"); +} + +QString DDSPreview::author() const +{ + return "AnyOldName3"; +} + +QString DDSPreview::description() const +{ + return tr("Displays DDS texture files using OpenGL."); +} + +VersionInfo DDSPreview::version() const +{ + return VersionInfo(1, 0, 0, VersionInfo::RELEASE_FINAL); +} + +QList<PluginSetting> DDSPreview::settings() const +{ + return {}; +} + +std::set<QString> DDSPreview::supportedExtensions() const +{ + return {"dds"}; +} + +bool DDSPreview::supportsArchives() const +{ + return true; +} + +QWidget* DDSPreview::genFilePreview(const QString& fileName, + const QSize& maxSize) const +{ + DDSFile dds; + if (!dds.loadFromFile(fileName)) { + QLabel* label = new QLabel(tr("Failed to load DDS file.")); + label->setAlignment(Qt::AlignCenter); + return label; + } + return buildPreview(dds, maxSize); +} + +QWidget* DDSPreview::genDataPreview(const QByteArray& fileData, + const QString& fileName, + const QSize& maxSize) const +{ + DDSFile dds; + if (!dds.loadFromData(fileData)) { + QLabel* label = new QLabel(tr("Failed to load DDS data.")); + label->setAlignment(Qt::AlignCenter); + return label; + } + return buildPreview(dds, maxSize); +} + +QWidget* DDSPreview::buildPreview(DDSFile& dds, const QSize& maxSize) const +{ + QWidget* container = new QWidget(); + QVBoxLayout* layout = new QVBoxLayout(container); + + // Description label + QLabel* descLabel = new QLabel(dds.description()); + descLabel->setAlignment(Qt::AlignCenter); + layout->addWidget(descLabel); + + // OpenGL preview widget — we need to keep the DDSFile alive, so store + // it in the container. We use a shared_ptr stored as a property. + auto* ddsPtr = new DDSFile(std::move(dds)); + DDSWidget* widget = new DDSWidget(*ddsPtr, container); + + // Clean up DDSFile when container is destroyed + QObject::connect(container, &QObject::destroyed, [ddsPtr]() { + delete ddsPtr; + }); + + // Size the widget proportionally + int previewW = maxSize.width(); + int previewH = maxSize.height() - 30; // leave room for label + if (ddsPtr->width() > 0 && ddsPtr->height() > 0) { + float texAspect = + static_cast<float>(ddsPtr->width()) / ddsPtr->height(); + float availAspect = + static_cast<float>(previewW) / std::max(1, previewH); + if (texAspect > availAspect) { + previewH = static_cast<int>(previewW / texAspect); + } else { + previewW = static_cast<int>(previewH * texAspect); + } + } + widget->setMinimumSize(std::min(previewW, static_cast<int>(ddsPtr->width())), + std::min(previewH, static_cast<int>(ddsPtr->height()))); + widget->setMaximumSize(previewW, previewH); + + layout->addWidget(widget); + container->setLayout(layout); + + return container; +} diff --git a/libs/preview_dds_native/src/ddspreview.h b/libs/preview_dds_native/src/ddspreview.h new file mode 100644 index 0000000..36f2bec --- /dev/null +++ b/libs/preview_dds_native/src/ddspreview.h @@ -0,0 +1,44 @@ +#ifndef DDSPREVIEW_H +#define DDSPREVIEW_H + +#include <set> + +#include <QString> + +#include <uibase/iplugin.h> +#include <uibase/ipluginpreview.h> + +class DDSPreview : public MOBase::IPluginPreview +{ + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginPreview) + Q_PLUGIN_METADATA(IID "org.tannin.DDSPreviewNative") + +public: + DDSPreview(); + +public: // IPlugin + bool init(MOBase::IOrganizer* moInfo) override; + QString name() const override; + QString localizedName() const override; + QString author() const override; + QString description() const override; + MOBase::VersionInfo version() const override; + QList<MOBase::PluginSetting> settings() const override; + +public: // IPluginPreview + std::set<QString> supportedExtensions() const override; + QWidget* genFilePreview(const QString& fileName, + const QSize& maxSize) const override; + bool supportsArchives() const override; + QWidget* genDataPreview(const QByteArray& fileData, + const QString& fileName, + const QSize& maxSize) const override; + +private: + QWidget* buildPreview(class DDSFile& dds, const QSize& maxSize) const; + + MOBase::IOrganizer* m_organizer = nullptr; +}; + +#endif // DDSPREVIEW_H diff --git a/libs/preview_dds_native/src/ddswidget.cpp b/libs/preview_dds_native/src/ddswidget.cpp new file mode 100644 index 0000000..43eb586 --- /dev/null +++ b/libs/preview_dds_native/src/ddswidget.cpp @@ -0,0 +1,252 @@ +#include "ddswidget.h" + +#include <QDebug> + +#include <algorithm> +#include <cstring> + +// Vertex data: position (x,y) + texcoord (u,v) +static const float quadVertices[] = { + // pos // tex + -1.0f, -1.0f, 0.0f, 1.0f, + 1.0f, -1.0f, 1.0f, 1.0f, + -1.0f, 1.0f, 0.0f, 0.0f, + 1.0f, 1.0f, 1.0f, 0.0f, +}; + +static const char* vertexShaderSrc = R"( +#version 330 core +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aTexCoord; +out vec2 vTexCoord; +uniform float uAspect; +uniform float uWidgetAspect; +void main() { + vec2 pos = aPos; + float ratio = uAspect / uWidgetAspect; + if (ratio > 1.0) + pos.y *= 1.0 / ratio; + else + pos.x *= ratio; + gl_Position = vec4(pos, 0.0, 1.0); + vTexCoord = aTexCoord; +} +)"; + +static const char* fragmentShaderSrc = R"( +#version 330 core +in vec2 vTexCoord; +out vec4 fragColor; +uniform sampler2D uTexture; +void main() { + vec4 texel = texture(uTexture, vTexCoord); + // Checkerboard for transparency + vec2 checker = floor(vTexCoord * 16.0); + float c = mod(checker.x + checker.y, 2.0); + vec3 bg = mix(vec3(0.6), vec3(0.4), c); + fragColor = vec4(mix(bg, texel.rgb, texel.a), 1.0); +} +)"; + +static const char* cubemapFragSrc = R"( +#version 330 core +in vec2 vTexCoord; +out vec4 fragColor; +uniform samplerCube uTexture; +void main() { + // Spherical projection for cubemap preview + float theta = vTexCoord.x * 6.28318530718; + float phi = vTexCoord.y * 3.14159265359; + vec3 dir = vec3(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta)); + vec4 texel = texture(uTexture, dir); + vec2 checker = floor(vTexCoord * 16.0); + float c = mod(checker.x + checker.y, 2.0); + vec3 bg = mix(vec3(0.6), vec3(0.4), c); + fragColor = vec4(mix(bg, texel.rgb, texel.a), 1.0); +} +)"; + +DDSWidget::DDSWidget(const DDSFile& dds, QWidget* parent) + : QOpenGLWidget(parent), m_dds(dds), m_vbo(QOpenGLBuffer::VertexBuffer) +{ + if (dds.height() > 0) { + m_aspectRatio = + static_cast<float>(dds.width()) / static_cast<float>(dds.height()); + } +} + +DDSWidget::~DDSWidget() +{ + makeCurrent(); + delete m_texture; + delete m_shader; + m_vbo.destroy(); + doneCurrent(); +} + +void DDSWidget::initializeGL() +{ + initializeOpenGLFunctions(); + + glClearColor(0.2f, 0.2f, 0.2f, 1.0f); + + // VBO + m_vbo.create(); + m_vbo.bind(); + m_vbo.allocate(quadVertices, sizeof(quadVertices)); + + setupShaders(); + uploadTexture(); +} + +void DDSWidget::setupShaders() +{ + m_shader = new QOpenGLShaderProgram(this); + m_shader->addShaderFromSourceCode(QOpenGLShader::Vertex, vertexShaderSrc); + + if (m_dds.isCubemap()) { + m_shader->addShaderFromSourceCode(QOpenGLShader::Fragment, cubemapFragSrc); + } else { + m_shader->addShaderFromSourceCode(QOpenGLShader::Fragment, fragmentShaderSrc); + } + + m_shader->link(); +} + +void DDSWidget::uploadTexture() +{ + if (m_dds.faceCount() == 0 || m_dds.mipCount() == 0) + return; + + const auto& fmt = m_dds.glFormat(); + + if (m_dds.isCubemap()) { + m_texture = new QOpenGLTexture(QOpenGLTexture::TargetCubeMap); + } else { + m_texture = new QOpenGLTexture(QOpenGLTexture::Target2D); + } + + m_texture->setAutoMipMapGenerationEnabled(false); + m_texture->setMipLevels(m_dds.mipCount()); + + if (m_dds.isCubemap()) { + m_texture->setSize(m_dds.width(), m_dds.height()); + // Set format for allocation + if (fmt.compressed) { + m_texture->setFormat( + static_cast<QOpenGLTexture::TextureFormat>(fmt.internalFormat)); + } else { + m_texture->setFormat( + static_cast<QOpenGLTexture::TextureFormat>(fmt.internalFormat)); + } + m_texture->allocateStorage(); + + static const QOpenGLTexture::CubeMapFace cubeMapFaces[] = { + QOpenGLTexture::CubeMapPositiveX, QOpenGLTexture::CubeMapNegativeX, + QOpenGLTexture::CubeMapPositiveY, QOpenGLTexture::CubeMapNegativeY, + QOpenGLTexture::CubeMapPositiveZ, QOpenGLTexture::CubeMapNegativeZ, + }; + + int numFaces = std::min(m_dds.faceCount(), 6); + for (int f = 0; f < numFaces; ++f) { + const auto& face = m_dds.face(f); + for (int m = 0; m < face.mips.size(); ++m) { + const auto& mip = face.mips[m]; + QByteArray pixelData = mip.data; + if (fmt.converter) { + pixelData = fmt.converter(mip.data, mip.width, mip.height); + } + if (fmt.compressed) { + m_texture->setCompressedData( + m, 0, cubeMapFaces[f], + pixelData.size(), + pixelData.constData()); + } else { + m_texture->setData( + m, 0, cubeMapFaces[f], + static_cast<QOpenGLTexture::PixelFormat>(fmt.format), + static_cast<QOpenGLTexture::PixelType>(fmt.type), + pixelData.constData()); + } + } + } + } else { + m_texture->setSize(m_dds.width(), m_dds.height()); + if (fmt.compressed) { + m_texture->setFormat( + static_cast<QOpenGLTexture::TextureFormat>(fmt.internalFormat)); + } else { + m_texture->setFormat( + static_cast<QOpenGLTexture::TextureFormat>(fmt.internalFormat)); + } + m_texture->allocateStorage(); + + const auto& face = m_dds.face(0); + for (int m = 0; m < face.mips.size(); ++m) { + const auto& mip = face.mips[m]; + QByteArray pixelData = mip.data; + if (fmt.converter) { + pixelData = fmt.converter(mip.data, mip.width, mip.height); + } + if (fmt.compressed) { + m_texture->setCompressedData( + m, 0, + pixelData.size(), + pixelData.constData()); + } else { + m_texture->setData( + m, 0, + static_cast<QOpenGLTexture::PixelFormat>(fmt.format), + static_cast<QOpenGLTexture::PixelType>(fmt.type), + pixelData.constData()); + } + } + } + + m_texture->setWrapMode(QOpenGLTexture::ClampToEdge); + if (fmt.sampler != SamplerType::Float) { + m_texture->setMinMagFilters(QOpenGLTexture::Nearest, + QOpenGLTexture::Nearest); + } else { + m_texture->setMinMagFilters(QOpenGLTexture::LinearMipMapLinear, + QOpenGLTexture::Linear); + } +} + +void DDSWidget::resizeGL(int w, int h) +{ + glViewport(0, 0, w, h); +} + +void DDSWidget::paintGL() +{ + glClear(GL_COLOR_BUFFER_BIT); + + if (!m_texture || !m_shader) + return; + + m_shader->bind(); + m_texture->bind(); + + float widgetAspect = width() > 0 && height() > 0 + ? static_cast<float>(width()) / height() + : 1.0f; + m_shader->setUniformValue("uAspect", m_aspectRatio); + m_shader->setUniformValue("uWidgetAspect", widgetAspect); + m_shader->setUniformValue("uTexture", 0); + + m_vbo.bind(); + m_shader->enableAttributeArray(0); + m_shader->enableAttributeArray(1); + m_shader->setAttributeBuffer(0, GL_FLOAT, 0, 2, 4 * sizeof(float)); + m_shader->setAttributeBuffer(1, GL_FLOAT, 2 * sizeof(float), 2, + 4 * sizeof(float)); + + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + m_shader->disableAttributeArray(0); + m_shader->disableAttributeArray(1); + m_vbo.release(); + m_texture->release(); + m_shader->release(); +} diff --git a/libs/preview_dds_native/src/ddswidget.h b/libs/preview_dds_native/src/ddswidget.h new file mode 100644 index 0000000..968e253 --- /dev/null +++ b/libs/preview_dds_native/src/ddswidget.h @@ -0,0 +1,36 @@ +#ifndef DDSWIDGET_H +#define DDSWIDGET_H + +#include "ddsfile.h" + +#include <QOpenGLBuffer> +#include <QOpenGLFunctions> +#include <QOpenGLShaderProgram> +#include <QOpenGLTexture> +#include <QOpenGLWidget> + +class DDSWidget : public QOpenGLWidget, protected QOpenGLFunctions +{ + Q_OBJECT + +public: + explicit DDSWidget(const DDSFile& dds, QWidget* parent = nullptr); + ~DDSWidget() override; + +protected: + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; + +private: + void uploadTexture(); + void setupShaders(); + + const DDSFile& m_dds; + QOpenGLTexture* m_texture = nullptr; + QOpenGLShaderProgram* m_shader = nullptr; + QOpenGLBuffer m_vbo; + float m_aspectRatio = 1.0f; +}; + +#endif // DDSWIDGET_H diff --git a/libs/script_extender_checker_native/CMakeLists.txt b/libs/script_extender_checker_native/CMakeLists.txt new file mode 100644 index 0000000..60ea2ec --- /dev/null +++ b/libs/script_extender_checker_native/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.16) + +project(script_extender_checker_native) + +add_subdirectory(src) diff --git a/libs/script_extender_checker_native/src/CMakeLists.txt b/libs/script_extender_checker_native/src/CMakeLists.txt new file mode 100644 index 0000000..7d63643 --- /dev/null +++ b/libs/script_extender_checker_native/src/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.16) + +file(GLOB script_extender_checker_native_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/*.h +) + +add_library(script_extender_checker_native SHARED ${script_extender_checker_native_SOURCES}) +mo2_configure_plugin(script_extender_checker_native NO_SOURCES WARNINGS OFF) +target_link_libraries(script_extender_checker_native PRIVATE mo2::uibase) +mo2_install_plugin(script_extender_checker_native) diff --git a/libs/script_extender_checker_native/src/scriptextenderchecker.cpp b/libs/script_extender_checker_native/src/scriptextenderchecker.cpp new file mode 100644 index 0000000..65276ad --- /dev/null +++ b/libs/script_extender_checker_native/src/scriptextenderchecker.cpp @@ -0,0 +1,363 @@ +#include "scriptextenderchecker.h" + +#include <uibase/iplugingame.h> +#include <uibase/pluginrequirements.h> + +#include <QCoreApplication> +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QMap> +#include <QRegularExpression> +#include <QStringConverter> +#include <QTextStream> + +using namespace MOBase; + +// Regex patterns matching SKSE/F4SE/etc log formats +static const QRegularExpression RE_NORMAL( + R"(plugin (?P<pluginPath>.+) \((?P<infoVersion>[\dA-Fa-f]{8}) (?P<name>.*) (?P<version>[\dA-Fa-f]{8})\) (?P<loadStatus>.+?)(?P<errorCode> \d+)?( \(handle \d+\))?\s*$)"); + +static const QRegularExpression RE_COULDNT_LOAD( + R"(couldn't load plugin (?P<pluginPath>.+) \(Error (?:code )?(?P<lastError>[-+]?\d+)(?::\s*(?P<seDetails>.*))?\)\s*)"); + +static const QRegularExpression RE_NOT_PLUGIN( + R"(plugin (?P<pluginPath>.+) does not appear to be an (?:SK|F4|NV|FO|OB)SE plugin\s*)"); + +ScriptExtenderChecker::ScriptExtenderChecker() : m_organizer(nullptr) {} + +const QMap<QString, ScriptExtenderChecker::GameType>& +ScriptExtenderChecker::supportedGames() +{ + static const QMap<QString, GameType> games = { + {"Skyrim", + {LogLocation::Docs, "SKSE/skse.log", "SKSE/skse_editor.log"}}, + {"Skyrim Special Edition", + {LogLocation::Docs, "SKSE/skse64.log", ""}}, + {"Skyrim VR", + {LogLocation::Docs, "SKSE/sksevr.log", ""}}, + {"Fallout 4", + {LogLocation::Docs, "F4SE/f4se.log", ""}}, + {"Oblivion", + {LogLocation::Install, "obse.log", "obse_editor.log"}}, + {"New Vegas", + {LogLocation::Install, "nvse.log", "nvse_editor.log"}}, + {"TTW", + {LogLocation::Install, "nvse.log", "nvse_editor.log"}}, + {"Fallout 3", + {LogLocation::Install, "fose.log", "fose_editor.log"}}, + }; + return games; +} + +bool ScriptExtenderChecker::init(IOrganizer* moInfo) +{ + m_organizer = moInfo; + m_organizer->onFinishedRun( + [this](const QString&, unsigned int) { invalidate(); }); + return true; +} + +QString ScriptExtenderChecker::name() const +{ + return "Script Extender Plugin Load Checker (Native)"; +} + +QString ScriptExtenderChecker::localizedName() const +{ + return tr("Script Extender Plugin Load Checker (Native)"); +} + +QString ScriptExtenderChecker::author() const +{ + return "AnyOldName3"; +} + +QString ScriptExtenderChecker::description() const +{ + return tr("Checks script extender log to see if any plugins failed to load."); +} + +VersionInfo ScriptExtenderChecker::version() const +{ + return VersionInfo(1, 2, 0, VersionInfo::RELEASE_FINAL); +} + +std::vector<std::shared_ptr<const IPluginRequirement>> +ScriptExtenderChecker::requirements() const +{ + const auto& games = supportedGames(); + return {PluginRequirementFactory::gameDependency(QStringList(games.keys()))}; +} + +QList<PluginSetting> ScriptExtenderChecker::settings() const +{ + return {}; +} + +std::vector<unsigned int> ScriptExtenderChecker::activeProblems() const +{ + if (!listBadPluginMessages().isEmpty()) { + return {PROBLEM_PLUGIN_LOAD}; + } + return {}; +} + +QString ScriptExtenderChecker::shortDescription(unsigned int key) const +{ + return tr("Script extender log reports incompatible plugins."); +} + +QString ScriptExtenderChecker::fullDescription(unsigned int key) const +{ + QStringList plugins = listBadPluginMessages(); + QString pluginListString = "\n \u2022 " + plugins.join("\n \u2022 "); + return tr("You have one or more script extender plugins which failed to " + "load!\n\n" + "If you want this notification to go away, here are some steps you " + "can take:\n" + " \u2022 Look for updates to the mod or the specific plugin " + "included in the mod.\n" + " \u2022 Disable the mod containing the plugin.\n" + " \u2022 Hide or delete the plugin from the mod.\n\n" + "To refresh the script extender logs, you will need to run the game " + "and/or editor again!\n\n" + "The failed plugins are:%1") + .arg(pluginListString); +} + +bool ScriptExtenderChecker::hasGuidedFix(unsigned int key) const +{ + return false; +} + +void ScriptExtenderChecker::startGuidedFix(unsigned int key) const {} + +QString ScriptExtenderChecker::resolveOrigin(const QString& pluginPath) const +{ + try { + QString dataDir = + m_organizer->managedGame()->dataDirectory().absolutePath(); + QString relativePath = QDir(dataDir).relativeFilePath(pluginPath); + QStringList origins = m_organizer->getFileOrigins(relativePath); + if (!origins.isEmpty()) { + return origins.first(); + } + } catch (...) { + } + return QString(); +} + +ScriptExtenderChecker::PluginMessage +ScriptExtenderChecker::parseNormalLine( + const QRegularExpressionMatch& match) const +{ + PluginMessage msg; + msg.pluginPath = match.captured("pluginPath"); + QString name = match.captured("name"); + QString version = match.captured("version"); + QString status = match.captured("loadStatus"); + msg.origin = resolveOrigin(msg.pluginPath); + msg.success = + msg.origin.isEmpty() || status == "loaded correctly" || status == "no version data"; + + if (!msg.success) { + QString trStatus = status; + // Translate known statuses + if (status == "disabled, address library needs to be updated") + trStatus = tr("disabled, address library needs to be updated"); + else if (status == "disabled, fatal error occurred while loading plugin") + trStatus = tr("disabled, fatal error occurred while loading plugin"); + else if (status == "disabled, bad version data") + trStatus = tr("disabled, bad version data"); + else if (status == "disabled, no name specified") + trStatus = tr("disabled, no name specified"); + else if (status == "disabled, unsupported version independence method") + trStatus = tr("disabled, unsupported version independence method"); + else if (status == "disabled, incompatible with current runtime version") + trStatus = tr("disabled, incompatible with current runtime version"); + else if (status == "disabled, requires newer script extender") + trStatus = tr("disabled, requires newer script extender"); + else if (status == "reported as incompatible during query") + trStatus = tr("reported as incompatible during query"); + else if (status == "reported as incompatible during load") + trStatus = tr("reported as incompatible during load"); + else if (status == + "disabled, fatal error occurred while checking plugin compatibility") + trStatus = tr("disabled, fatal error occurred while checking plugin " + "compatibility"); + else if (status == "disabled, fatal error occurred while querying plugin") + trStatus = tr("disabled, fatal error occurred while querying plugin"); + + msg.message = tr("%1 version %2 (%3, %4) %5.") + .arg(name) + .arg(version) + .arg(QFileInfo(msg.pluginPath).fileName()) + .arg(msg.origin) + .arg(trStatus); + } + return msg; +} + +ScriptExtenderChecker::PluginMessage +ScriptExtenderChecker::parseCouldntLoadLine( + const QRegularExpressionMatch& match) const +{ + PluginMessage msg; + msg.pluginPath = match.captured("pluginPath"); + int lastError = match.captured("lastError").toInt(); + QString details = match.captured("seDetails").trimmed(); + msg.origin = resolveOrigin(msg.pluginPath); + msg.success = msg.origin.isEmpty(); + + if (!msg.success) { + QString fileName = QFileInfo(msg.pluginPath).fileName(); + if (lastError == 126) { + msg.message = + tr("Couldn't load %1 (%2). A dependency DLL could not be found " + "(code %3). %4") + .arg(fileName) + .arg(msg.origin) + .arg(lastError) + .arg(details); + } else if (lastError == 193) { + msg.message = tr("Couldn't load %1 (%2). A DLL is invalid (code %3).") + .arg(fileName) + .arg(msg.origin) + .arg(lastError); + } else { + msg.message = + tr("Couldn't load %1 (%2). The last error code was %3.") + .arg(fileName) + .arg(msg.origin) + .arg(lastError); + } + } + return msg; +} + +ScriptExtenderChecker::PluginMessage +ScriptExtenderChecker::parseNotAPluginLine( + const QRegularExpressionMatch& match) const +{ + PluginMessage msg; + msg.pluginPath = match.captured("pluginPath"); + msg.origin = resolveOrigin(msg.pluginPath); + msg.success = msg.origin.isEmpty(); + + if (!msg.success) { + msg.message = + tr("%1 (%2) does not appear to be a script extender plugin.") + .arg(QFileInfo(msg.pluginPath).fileName()) + .arg(msg.origin); + } + return msg; +} + +QList<ScriptExtenderChecker::PluginMessage> +ScriptExtenderChecker::parseLog(const QString& logPath) const +{ + QList<PluginMessage> messages; + + QFile file(logPath); + if (!file.exists() || !file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return messages; + } + + // Script extender logs use cp1252 encoding + QTextStream stream(&file); +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + stream.setCodec("Windows-1252"); +#else + stream.setEncoding(QStringConverter::Latin1); +#endif + + while (!stream.atEnd()) { + QString line = stream.readLine() + "\n"; + + QRegularExpressionMatch match = RE_NORMAL.match(line); + if (match.hasMatch()) { + messages.append(parseNormalLine(match)); + continue; + } + + match = RE_COULDNT_LOAD.match(line); + if (match.hasMatch()) { + messages.append(parseCouldntLoadLine(match)); + continue; + } + + match = RE_NOT_PLUGIN.match(line); + if (match.hasMatch()) { + messages.append(parseNotAPluginLine(match)); + continue; + } + } + + return messages; +} + +QStringList ScriptExtenderChecker::listBadPluginMessages() const +{ + const auto& games = supportedGames(); + QString gameName = m_organizer->managedGame()->gameName(); + + if (!games.contains(gameName)) { + return {}; + } + + const GameType& gameType = games[gameName]; + + QString baseDir; + if (gameType.base == LogLocation::Docs) { + baseDir = m_organizer->managedGame()->documentsDirectory().absolutePath(); + } else { + baseDir = m_organizer->managedGame()->gameDirectory().absolutePath(); + } + + QList<PluginMessage> gameMessages; + QList<PluginMessage> editorMessages; + + if (!gameType.gameSuffix.isEmpty()) { + gameMessages = parseLog(QDir(baseDir).filePath(gameType.gameSuffix)); + } + if (!gameType.editorSuffix.isEmpty()) { + editorMessages = parseLog(QDir(baseDir).filePath(gameType.editorSuffix)); + } + + QStringList result; + + // Report game log failures that aren't successful in editor log + for (const auto& gameMsg : gameMessages) { + if (!gameMsg.success) { + bool editorOk = false; + for (const auto& editorMsg : editorMessages) { + if (gameMsg.pluginPath == editorMsg.pluginPath && editorMsg.success) { + editorOk = true; + break; + } + } + if (!editorOk && !result.contains(gameMsg.message)) { + result.append(gameMsg.message); + } + } + } + + // Report editor log failures that aren't successful in game log + for (const auto& editorMsg : editorMessages) { + if (!editorMsg.success) { + bool gameOk = false; + for (const auto& gameMsg : gameMessages) { + if (editorMsg.pluginPath == gameMsg.pluginPath && gameMsg.success) { + gameOk = true; + break; + } + } + if (!gameOk && !result.contains(editorMsg.message)) { + result.append(editorMsg.message); + } + } + } + + return result; +} diff --git a/libs/script_extender_checker_native/src/scriptextenderchecker.h b/libs/script_extender_checker_native/src/scriptextenderchecker.h new file mode 100644 index 0000000..8dbadcb --- /dev/null +++ b/libs/script_extender_checker_native/src/scriptextenderchecker.h @@ -0,0 +1,72 @@ +#ifndef SCRIPTEXTENDERCHECKER_H +#define SCRIPTEXTENDERCHECKER_H + +#include <memory> + +#include <QString> +#include <QStringList> + +#include <uibase/iplugin.h> +#include <uibase/iplugindiagnose.h> +#include <uibase/pluginrequirements.h> + +class ScriptExtenderChecker : public QObject, + public MOBase::IPlugin, + public MOBase::IPluginDiagnose +{ + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin MOBase::IPluginDiagnose) + Q_PLUGIN_METADATA(IID "org.tannin.ScriptExtenderCheckerNative") + +public: + ScriptExtenderChecker(); + +public: // IPlugin + bool init(MOBase::IOrganizer* moInfo) override; + QString name() const override; + QString localizedName() const override; + QString author() const override; + QString description() const override; + MOBase::VersionInfo version() const override; + std::vector<std::shared_ptr<const MOBase::IPluginRequirement>> + requirements() const override; + QList<MOBase::PluginSetting> settings() const override; + +public: // IPluginDiagnose + std::vector<unsigned int> activeProblems() const override; + QString shortDescription(unsigned int key) const override; + QString fullDescription(unsigned int key) const override; + bool hasGuidedFix(unsigned int key) const override; + void startGuidedFix(unsigned int key) const override; + +private: + static const unsigned int PROBLEM_PLUGIN_LOAD = 0; + + enum class LogLocation { Docs, Install }; + + struct GameType { + LogLocation base; + QString gameSuffix; + QString editorSuffix; // empty if no editor log + }; + + struct PluginMessage { + QString pluginPath; + QString origin; + QString message; + bool success; + }; + + QStringList listBadPluginMessages() const; + QList<PluginMessage> parseLog(const QString& logPath) const; + PluginMessage parseNormalLine(const QRegularExpressionMatch& match) const; + PluginMessage parseCouldntLoadLine(const QRegularExpressionMatch& match) const; + PluginMessage parseNotAPluginLine(const QRegularExpressionMatch& match) const; + QString resolveOrigin(const QString& pluginPath) const; + + static const QMap<QString, GameType>& supportedGames(); + + MOBase::IOrganizer* m_organizer; +}; + +#endif // SCRIPTEXTENDERCHECKER_H diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt index d7a7642..30dd8b5 100644 --- a/src/src/CMakeLists.txt +++ b/src/src/CMakeLists.txt @@ -121,7 +121,7 @@ target_compile_definitions(organizer PRIVATE if(NOT WIN32) option(MO2_BUNDLE_7Z_RUNTIME "Copy a Linux 7z module into organizer/dlls" ON) option(MO2_STAGE_PYTHON_PLUGIN_PAYLOAD - "Stage shipped Python plugin payload into build plugins/ for Linux runs" ON) + "Stage shipped Python plugin payload into build plugins/ for Linux runs" OFF) option(MO2_STAGE_INSTALLER_WIZARD "Stage installer_wizard python plugin payload (requires additional wizard package)" OFF) set(MO2_7Z_SO_PATH "" CACHE FILEPATH diff --git a/src/src/envshortcut.cpp b/src/src/envshortcut.cpp index e78e904..5b6ff8e 100644 --- a/src/src/envshortcut.cpp +++ b/src/src/envshortcut.cpp @@ -377,6 +377,14 @@ static QString appImageOrBinary() if (!appImage.isEmpty() && QFile::exists(appImage)) {
return appImage;
}
+ // Prefer the fluorine-manager launcher script over the bare ModOrganizer-core
+ // binary — the launcher sets up bundled library paths, Qt plugin paths, etc.
+ // Without it, shortcuts fail on systems that don't have all deps in PATH.
+ const QString appDir = QCoreApplication::applicationDirPath();
+ const QString launcher = appDir + "/fluorine-manager";
+ if (QFile::exists(launcher)) {
+ return QFileInfo(launcher).absoluteFilePath();
+ }
return QFileInfo(qApp->applicationFilePath()).absoluteFilePath();
}
diff --git a/src/src/plugincontainer.cpp b/src/src/plugincontainer.cpp index e215c0a..a170c99 100644 --- a/src/src/plugincontainer.cpp +++ b/src/src/plugincontainer.cpp @@ -9,6 +9,7 @@ #include <QDirIterator>
#include <QMessageBox>
#include <QToolButton>
+#include <QSettings>
#include <cstdio>
#include <boost/fusion/algorithm/iteration/for_each.hpp>
#include <boost/fusion/include/at_key.hpp>
@@ -1304,6 +1305,17 @@ void PluginContainer::loadPlugins() }
}
+ // Skip the Python proxy plugin unless the user has enabled Python support
+ // in Settings > Python. Native C++ replacements handle all default plugins.
+ if (iter.fileName() == "libplugin_python.so" ||
+ iter.fileName() == "plugin_python.dll") {
+ if (!QSettings().value("fluorine/python_enabled", false).toBool()) {
+ log::debug("plugin \"{}\" skipped (Python plugins disabled in settings)",
+ iter.fileName());
+ continue;
+ }
+ }
+
if (loadCheck.isOpen()) {
loadCheck.write(iter.fileName().toUtf8());
loadCheck.write("\n");
diff --git a/src/src/settingsdialog.cpp b/src/src/settingsdialog.cpp index 919bf4e..757c6f8 100644 --- a/src/src/settingsdialog.cpp +++ b/src/src/settingsdialog.cpp @@ -21,12 +21,13 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "settingsdialogdiagnostics.h"
#include "settingsdialoggeneral.h"
#include "settingsdialogmodlist.h"
-#include "settingsdialognexus.h" -#include "settingsdialogpaths.h" -#include "settingsdialogplugins.h" -#include "settingsdialogproton.h" -#include "settingsdialogtheme.h" -#include "settingsdialogworkarounds.h" +#include "settingsdialognexus.h"
+#include "settingsdialogpaths.h"
+#include "settingsdialogplugins.h"
+#include "settingsdialogproton.h"
+#include "settingsdialogpython.h"
+#include "settingsdialogtheme.h"
+#include "settingsdialogworkarounds.h"
#include "ui_settingsdialog.h"
using namespace MOBase;
@@ -46,14 +47,16 @@ SettingsDialog::SettingsDialog(PluginContainer* pluginContainer, Settings& setti m_tabs.push_back(std::unique_ptr<SettingsTab>(new PathsSettingsTab(settings, *this)));
m_tabs.push_back(
std::unique_ptr<SettingsTab>(new DiagnosticsSettingsTab(settings, *this)));
- m_tabs.push_back(std::unique_ptr<SettingsTab>(new NexusSettingsTab(settings, *this))); - m_tabs.push_back(std::unique_ptr<SettingsTab>( - new PluginsSettingsTab(settings, m_pluginContainer, *this))); - m_tabs.push_back( - std::unique_ptr<SettingsTab>(new ProtonSettingsTab(settings, *this))); - m_tabs.push_back( - std::unique_ptr<SettingsTab>(new WorkaroundsSettingsTab(settings, *this))); -} + m_tabs.push_back(std::unique_ptr<SettingsTab>(new NexusSettingsTab(settings, *this)));
+ m_tabs.push_back(std::unique_ptr<SettingsTab>(
+ new PluginsSettingsTab(settings, m_pluginContainer, *this)));
+ m_tabs.push_back(
+ std::unique_ptr<SettingsTab>(new ProtonSettingsTab(settings, *this)));
+ m_tabs.push_back(
+ std::unique_ptr<SettingsTab>(new PythonSettingsTab(settings, *this)));
+ m_tabs.push_back(
+ std::unique_ptr<SettingsTab>(new WorkaroundsSettingsTab(settings, *this)));
+}
PluginContainer* SettingsDialog::pluginContainer()
{
diff --git a/src/src/settingsdialog.ui b/src/src/settingsdialog.ui index 646b9b6..2089c06 100644 --- a/src/src/settingsdialog.ui +++ b/src/src/settingsdialog.ui @@ -1897,6 +1897,167 @@ If you disable this feature, MO will only display official DLCs this way. Please </item> </layout> </widget> + <widget class="QWidget" name="pythonTab"> + <attribute name="title"> + <string>Python</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_python"> + <item> + <widget class="QGroupBox" name="groupBox_python"> + <property name="title"> + <string>Python Plugin Support</string> + </property> + <layout class="QGridLayout" name="gridLayout_python"> + <item row="0" column="0"> + <widget class="QLabel" name="pythonEnableLabel"> + <property name="text"> + <string>Enable Python Plugins:</string> + </property> + </widget> + </item> + <item row="0" column="1" colspan="2"> + <widget class="QCheckBox" name="pythonEnableCheck"> + <property name="text"> + <string>Load .py plugins (requires Python 3 and PyQt6)</string> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="pythonPathLabel"> + <property name="text"> + <string>Python Path:</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QLineEdit" name="pythonPathEdit"> + <property name="readOnly"> + <bool>true</bool> + </property> + <property name="placeholderText"> + <string>Auto-detected</string> + </property> + </widget> + </item> + <item row="1" column="2"> + <widget class="QPushButton" name="pythonRefreshButton"> + <property name="text"> + <string>Detect</string> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="pythonVersionLabel"> + <property name="text"> + <string>Version:</string> + </property> + </widget> + </item> + <item row="2" column="1" colspan="2"> + <widget class="QLabel" name="pythonVersionValue"> + <property name="text"> + <string>Not detected</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox_venv"> + <property name="title"> + <string>Virtual Environment</string> + </property> + <layout class="QGridLayout" name="gridLayout_venv"> + <item row="0" column="0"> + <widget class="QLabel" name="venvPathLabel"> + <property name="text"> + <string>Venv Location:</string> + </property> + </widget> + </item> + <item row="0" column="1" colspan="2"> + <widget class="QLineEdit" name="venvPathEdit"> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="venvStatusLabel"> + <property name="text"> + <string>Status:</string> + </property> + </widget> + </item> + <item row="1" column="1" colspan="2"> + <widget class="QLabel" name="venvStatusValue"> + <property name="text"> + <string>Not created</string> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QPushButton" name="createVenvButton"> + <property name="text"> + <string>Create Virtual Environment</string> + </property> + <property name="toolTip"> + <string>Creates a Python virtual environment and installs PyQt6. Required for loading .py plugins.</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QPushButton" name="deleteVenvButton"> + <property name="text"> + <string>Delete Venv</string> + </property> + </widget> + </item> + <item row="2" column="2"> + <widget class="QPushButton" name="openVenvFolderButton"> + <property name="text"> + <string>Open Folder</string> + </property> + </widget> + </item> + <item row="3" column="0" colspan="3"> + <widget class="QTextEdit" name="pythonLogOutput"> + <property name="readOnly"> + <bool>true</bool> + </property> + <property name="visible"> + <bool>false</bool> + </property> + <property name="maximumHeight"> + <number>150</number> + </property> + <property name="font"> + <font> + <family>monospace</family> + <pointsize>9</pointsize> + </font> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <spacer name="verticalSpacer_python"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> <widget class="QWidget" name="workaroundTab"> <attribute name="title"> <string>Workarounds</string> diff --git a/src/src/settingsdialogpython.cpp b/src/src/settingsdialogpython.cpp new file mode 100644 index 0000000..ebad6eb --- /dev/null +++ b/src/src/settingsdialogpython.cpp @@ -0,0 +1,206 @@ +#include "settingsdialogpython.h" + +#include "fluorinepaths.h" +#include "ui_settingsdialog.h" + +#include <log.h> +#include <uibase/utility.h> + +#include <QCheckBox> +#include <QDir> +#include <QFileInfo> +#include <QMessageBox> +#include <QProcess> +#include <QPushButton> +#include <QSettings> +#include <QStandardPaths> + +PythonSettingsTab::PythonSettingsTab(Settings& s, SettingsDialog& d) + : QObject(&d), SettingsTab(s, d) +{ + m_venvPath = fluorineDataDir() + "/python-venv"; + ui->venvPathEdit->setText(m_venvPath); + + // Load saved state + bool pythonEnabled = QSettings().value("fluorine/python_enabled", false).toBool(); + ui->pythonEnableCheck->setChecked(pythonEnabled); + + detectPython(); + refreshVenvState(); + + QObject::connect(ui->pythonRefreshButton, &QPushButton::clicked, this, + &PythonSettingsTab::detectPython); + QObject::connect(ui->createVenvButton, &QPushButton::clicked, this, + &PythonSettingsTab::onCreateVenv); + QObject::connect(ui->deleteVenvButton, &QPushButton::clicked, this, + &PythonSettingsTab::onDeleteVenv); + QObject::connect(ui->openVenvFolderButton, &QPushButton::clicked, this, + &PythonSettingsTab::onOpenVenvFolder); +} + +void PythonSettingsTab::update() +{ + QSettings().setValue("fluorine/python_enabled", + ui->pythonEnableCheck->isChecked()); +} + +void PythonSettingsTab::detectPython() +{ + m_pythonPath = QStandardPaths::findExecutable("python3"); + if (m_pythonPath.isEmpty()) { + m_pythonPath = QStandardPaths::findExecutable("python"); + } + + if (m_pythonPath.isEmpty()) { + ui->pythonPathEdit->setText(""); + ui->pythonVersionValue->setText("Python 3 not found in PATH"); + return; + } + + ui->pythonPathEdit->setText(m_pythonPath); + + // Get version + QProcess proc; + proc.start(m_pythonPath, {"--version"}); + proc.waitForFinished(5000); + + if (proc.exitCode() == 0) { + QString version = QString::fromUtf8(proc.readAllStandardOutput()).trimmed(); + if (version.isEmpty()) { + version = QString::fromUtf8(proc.readAllStandardError()).trimmed(); + } + ui->pythonVersionValue->setText(version); + } else { + ui->pythonVersionValue->setText("Error detecting version"); + } +} + +void PythonSettingsTab::refreshVenvState() +{ + QDir venvDir(m_venvPath); + bool exists = venvDir.exists() && venvDir.exists("bin/python3"); + + if (exists) { + // Check if PyQt6 is installed + QProcess proc; + proc.start(m_venvPath + "/bin/python3", + {"-c", "import PyQt6; print(PyQt6.__file__)"}); + proc.waitForFinished(5000); + + if (proc.exitCode() == 0) { + ui->venvStatusValue->setText("Active (PyQt6 installed)"); + } else { + ui->venvStatusValue->setText("Active (PyQt6 NOT installed)"); + } + } else { + ui->venvStatusValue->setText("Not created"); + } + + ui->deleteVenvButton->setEnabled(exists); + ui->openVenvFolderButton->setEnabled(exists); +} + +void PythonSettingsTab::onCreateVenv() +{ + if (m_pythonPath.isEmpty()) { + QMessageBox::warning(parentWidget(), tr("No Python"), + tr("Python 3 was not found in PATH.\n\n" + "Install Python 3 using your package manager:\n" + " Arch: pacman -S python\n" + " Ubuntu: apt install python3 python3-venv\n" + " Fedora: dnf install python3")); + return; + } + + ui->pythonLogOutput->setVisible(true); + ui->pythonLogOutput->clear(); + ui->venvStatusValue->setText("Creating virtual environment..."); + + // Create venv + { + QProcess proc; + proc.start(m_pythonPath, {"-m", "venv", m_venvPath}); + proc.waitForFinished(30000); + + QString output = QString::fromUtf8(proc.readAllStandardOutput()); + QString errors = QString::fromUtf8(proc.readAllStandardError()); + if (!output.isEmpty()) + ui->pythonLogOutput->append(output); + if (!errors.isEmpty()) + ui->pythonLogOutput->append(errors); + + if (proc.exitCode() != 0) { + ui->venvStatusValue->setText("Failed to create venv"); + ui->pythonLogOutput->append("venv creation failed with exit code " + + QString::number(proc.exitCode())); + return; + } + } + + ui->pythonLogOutput->append("Virtual environment created. Installing PyQt6..."); + ui->venvStatusValue->setText("Installing PyQt6..."); + + // Install PyQt6 + { + QProcess proc; + proc.start(m_venvPath + "/bin/pip", + {"install", "--upgrade", "pip", "PyQt6"}); + proc.waitForFinished(120000); // PyQt6 is large, give it time + + QString output = QString::fromUtf8(proc.readAllStandardOutput()); + QString errors = QString::fromUtf8(proc.readAllStandardError()); + if (!output.isEmpty()) + ui->pythonLogOutput->append(output); + if (!errors.isEmpty()) + ui->pythonLogOutput->append(errors); + + if (proc.exitCode() != 0) { + ui->venvStatusValue->setText("PyQt6 installation failed"); + ui->pythonLogOutput->append("pip install failed with exit code " + + QString::number(proc.exitCode())); + refreshVenvState(); + return; + } + } + + ui->pythonLogOutput->append("PyQt6 installed successfully."); + MOBase::log::info("Python venv created at {}", m_venvPath); + + refreshVenvState(); +} + +void PythonSettingsTab::onDeleteVenv() +{ + QDir venvDir(m_venvPath); + if (!venvDir.exists()) { + refreshVenvState(); + return; + } + + auto result = QMessageBox::question( + parentWidget(), tr("Delete Virtual Environment"), + tr("Delete the Python virtual environment at:\n%1\n\n" + "This will disable .py plugin loading until recreated.") + .arg(m_venvPath)); + + if (result != QMessageBox::Yes) + return; + + if (venvDir.removeRecursively()) { + ui->pythonLogOutput->setVisible(false); + MOBase::log::info("Python venv deleted at {}", m_venvPath); + } else { + QMessageBox::warning(parentWidget(), tr("Error"), + tr("Failed to delete the virtual environment.")); + } + + refreshVenvState(); +} + +void PythonSettingsTab::onOpenVenvFolder() +{ + QDir venvDir(m_venvPath); + if (venvDir.exists()) { + MOBase::shell::Explore(venvDir); + } +} diff --git a/src/src/settingsdialogpython.h b/src/src/settingsdialogpython.h new file mode 100644 index 0000000..187d04b --- /dev/null +++ b/src/src/settingsdialogpython.h @@ -0,0 +1,29 @@ +#ifndef SETTINGSDIALOGPYTHON_H +#define SETTINGSDIALOGPYTHON_H + +#include <QObject> + +#include "settings.h" +#include "settingsdialog.h" + +class PythonSettingsTab : public QObject, public SettingsTab +{ + Q_OBJECT + +public: + PythonSettingsTab(Settings& settings, SettingsDialog& dialog); + + void update() override; + +private: + void detectPython(); + void refreshVenvState(); + void onCreateVenv(); + void onDeleteVenv(); + void onOpenVenvFolder(); + + QString m_pythonPath; + QString m_venvPath; +}; + +#endif // SETTINGSDIALOGPYTHON_H diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 2b1ef5f..22116f0 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -28,7 +28,8 @@ constexpr double ATTR_CACHE_SECONDS = 86400.0; void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid); void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid, uint64_t size, - const std::chrono::system_clock::time_point& mtime); + const std::chrono::system_clock::time_point& mtime, + const std::string& real_path = {}); void maybeLogCounters(Mo2FsContext* ctx) { @@ -209,6 +210,7 @@ struct ChildSnapshot bool is_dir = false; uint64_t size = 0; std::chrono::system_clock::time_point mtime{}; + std::string real_path; }; std::vector<ChildSnapshot> listChildrenSnapshot( @@ -229,8 +231,9 @@ std::vector<ChildSnapshot> listChildrenSnapshot( snap.name = name; snap.is_dir = child->is_directory; if (!child->is_directory) { - snap.size = child->file_info.size; - snap.mtime = child->file_info.mtime; + snap.size = child->file_info.size; + snap.mtime = child->file_info.mtime; + snap.real_path = child->file_info.real_path; } out.push_back(std::move(snap)); } @@ -256,7 +259,8 @@ std::vector<Mo2FsContext::DirEntry> buildDirEntries( const std::string childPath = joinPath(path, child.name); entries.push_back( Mo2FsContext::DirEntry{ctx->inodes->getOrCreate(childPath), child.name, - child.is_dir, child.size, child.mtime}); + child.is_dir, child.size, child.mtime, + child.real_path}); } return entries; @@ -329,8 +333,20 @@ std::vector<char> buildReaddirBlob( for (const auto& entry : entries) { struct stat st; std::memset(&st, 0, sizeof(st)); - st.st_ino = entry.ino; - st.st_mode = entry.is_dir ? (S_IFDIR | 0755) : (S_IFREG | 0644); + st.st_ino = entry.ino; + if (entry.is_dir) { + st.st_mode = S_IFDIR | 0755; + } else { + // Preserve executable bits from the real file on disk. + mode_t mode = 0644; + if (!entry.real_path.empty()) { + struct stat real_st; + if (::stat(entry.real_path.c_str(), &real_st) == 0) { + mode = real_st.st_mode & 0777; + } + } + st.st_mode = S_IFREG | mode; + } const size_t entSize = fuse_add_direntry(req, nullptr, 0, entry.name.c_str(), &st, 0); @@ -362,7 +378,7 @@ std::vector<char> buildReaddirPlusBlob( fillStatForDir(&e.attr, entry.ino, ctx->uid, ctx->gid); } else { fillStatForFile(&e.attr, entry.ino, ctx->uid, ctx->gid, entry.size, - entry.mtime); + entry.mtime, entry.real_path); } const size_t entSize = @@ -444,16 +460,27 @@ void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid) void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid, uint64_t size, - const std::chrono::system_clock::time_point& mtime) + const std::chrono::system_clock::time_point& mtime, + const std::string& real_path = {}) { std::memset(st, 0, sizeof(struct stat)); st->st_ino = ino; - st->st_mode = S_IFREG | 0644; st->st_nlink = 1; st->st_uid = uid; st->st_gid = gid; st->st_size = static_cast<off_t>(size); + // Preserve executable bits from the real file on disk so native Linux + // executables added as mods can actually be launched through the VFS. + mode_t mode = 0644; + if (!real_path.empty()) { + struct stat real_st; + if (::stat(real_path.c_str(), &real_st) == 0) { + mode = real_st.st_mode & 0777; + } + } + st->st_mode = S_IFREG | mode; + const auto secs = std::chrono::duration_cast<std::chrono::seconds>( mtime.time_since_epoch()); st->st_mtim.tv_sec = secs.count(); @@ -473,7 +500,8 @@ void replyEntryFromSnapshot(fuse_req_t req, const Mo2FsContext* ctx, fuse_ino_t if (snap.is_directory) { fillStatForDir(&e.attr, ino, ctx->uid, ctx->gid); } else { - fillStatForFile(&e.attr, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + fillStatForFile(&e.attr, ino, ctx->uid, ctx->gid, snap.size, snap.mtime, + snap.real_path); } fuse_reply_entry(req, &e); @@ -683,7 +711,8 @@ void mo2_getattr(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* /*fi*/) if (snap.is_directory) { fillStatForDir(&st, ino, ctx->uid, ctx->gid); } else { - fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime, + snap.real_path); } { @@ -800,8 +829,19 @@ void mo2_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, for (size_t i = static_cast<size_t>(off); i < entries->size(); ++i) { struct stat st; std::memset(&st, 0, sizeof(st)); - st.st_ino = (*entries)[i].ino; - st.st_mode = (*entries)[i].is_dir ? (S_IFDIR | 0755) : (S_IFREG | 0644); + st.st_ino = (*entries)[i].ino; + if ((*entries)[i].is_dir) { + st.st_mode = S_IFDIR | 0755; + } else { + mode_t mode = 0644; + if (!(*entries)[i].real_path.empty()) { + struct stat real_st; + if (::stat((*entries)[i].real_path.c_str(), &real_st) == 0) { + mode = real_st.st_mode & 0777; + } + } + st.st_mode = S_IFREG | mode; + } const size_t ent = fuse_add_direntry(req, buf.data() + used, size - used, (*entries)[i].name.c_str(), &st, @@ -1164,7 +1204,7 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size, fuse_reply_write(req, static_cast<size_t>(written)); } -void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mode*/, +void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t mode, struct fuse_file_info* fi) { Mo2FsContext* ctx = getContext(req); @@ -1196,7 +1236,8 @@ void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mo std::error_code ec; fs::create_directories(fs::path(realPath).parent_path(), ec); // Create the file - int tmpFd = open(realPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + int tmpFd = open(realPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, + mode != 0 ? (mode & 07777) : 0644); if (tmpFd < 0) { // Fall back to staging trackedMod.clear(); @@ -1259,7 +1300,8 @@ void mo2_create(fuse_req_t req, fuse_ino_t parent, const char* name, mode_t /*mo e.ino = newIno; e.attr_timeout = TTL_SECONDS; e.entry_timeout = TTL_SECONDS; - fillStatForFile(&e.attr, newIno, ctx->uid, ctx->gid, snap.size, snap.mtime); + fillStatForFile(&e.attr, newIno, ctx->uid, ctx->gid, snap.size, snap.mtime, + snap.real_path); fuse_reply_create(req, &e, fi); } @@ -1447,6 +1489,14 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, updateFileNode(ctx, path, target, targetIsTracked ? "Mod" : "Staging"); } + // Handle chmod — propagate permission changes to the real file on disk. + if ((to_set & FUSE_SET_ATTR_MODE) != 0 && attr != nullptr) { + const auto snap = snapshotForPath(ctx, path); + if (snap.found && !snap.is_directory && !snap.real_path.empty()) { + ::chmod(snap.real_path.c_str(), attr->st_mode & 07777); + } + } + // Handle explicit timestamp changes (utimensat / Wine SetFileTime) if ((to_set & (FUSE_SET_ATTR_MTIME | FUSE_SET_ATTR_MTIME_NOW | FUSE_SET_ATTR_ATIME | FUSE_SET_ATTR_ATIME_NOW)) != 0 && @@ -1523,7 +1573,8 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set, if (snap.is_directory) { fillStatForDir(&st, ino, ctx->uid, ctx->gid); } else { - fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime); + fillStatForFile(&st, ino, ctx->uid, ctx->gid, snap.size, snap.mtime, + snap.real_path); } fuse_reply_attr(req, &st, TTL_SECONDS); } diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h index 3877bbb..40e0ed7 100644 --- a/src/src/vfs/mo2filesystem.h +++ b/src/src/vfs/mo2filesystem.h @@ -51,6 +51,7 @@ struct Mo2FsContext bool is_dir = false; uint64_t size = 0; std::chrono::system_clock::time_point mtime{}; + std::string real_path; }; struct OpenDir { |
