aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt4
-rw-r--r--docker/Dockerfile9
-rwxr-xr-xdocker/build-inner.sh184
-rw-r--r--libs/basic_games_native/CMakeLists.txt5
-rw-r--r--libs/basic_games_native/src/CMakeLists.txt11
-rw-r--r--libs/basic_games_native/src/basicgameplugin.cpp380
-rw-r--r--libs/basic_games_native/src/basicgameplugin.h73
-rw-r--r--libs/basic_games_native/src/basicgamesproxy.cpp99
-rw-r--r--libs/basic_games_native/src/basicgamesproxy.h41
-rw-r--r--libs/basic_games_native/src/gamedefs.cpp1024
-rw-r--r--libs/basic_games_native/src/gamedefs.h61
-rw-r--r--libs/basic_games_native/src/steamutils.cpp156
-rw-r--r--libs/basic_games_native/src/steamutils.h14
-rw-r--r--libs/form43_checker_native/CMakeLists.txt5
-rw-r--r--libs/form43_checker_native/src/CMakeLists.txt11
-rw-r--r--libs/form43_checker_native/src/form43checker.cpp131
-rw-r--r--libs/form43_checker_native/src/form43checker.h53
-rw-r--r--libs/plugin_python/src/runner/pythonrunner.cpp182
-rw-r--r--libs/preview_dds_native/CMakeLists.txt5
-rw-r--r--libs/preview_dds_native/src/CMakeLists.txt17
-rw-r--r--libs/preview_dds_native/src/ddsfile.cpp132
-rw-r--r--libs/preview_dds_native/src/ddsfile.h47
-rw-r--r--libs/preview_dds_native/src/ddsformat.cpp619
-rw-r--r--libs/preview_dds_native/src/ddsformat.h200
-rw-r--r--libs/preview_dds_native/src/ddspreview.cpp126
-rw-r--r--libs/preview_dds_native/src/ddspreview.h44
-rw-r--r--libs/preview_dds_native/src/ddswidget.cpp252
-rw-r--r--libs/preview_dds_native/src/ddswidget.h36
-rw-r--r--libs/script_extender_checker_native/CMakeLists.txt5
-rw-r--r--libs/script_extender_checker_native/src/CMakeLists.txt11
-rw-r--r--libs/script_extender_checker_native/src/scriptextenderchecker.cpp363
-rw-r--r--libs/script_extender_checker_native/src/scriptextenderchecker.h72
-rw-r--r--src/src/CMakeLists.txt2
-rw-r--r--src/src/envshortcut.cpp8
-rw-r--r--src/src/plugincontainer.cpp12
-rw-r--r--src/src/settingsdialog.cpp31
-rw-r--r--src/src/settingsdialog.ui161
-rw-r--r--src/src/settingsdialogpython.cpp206
-rw-r--r--src/src/settingsdialogpython.h29
-rw-r--r--src/src/vfs/mo2filesystem.cpp85
-rw-r--r--src/src/vfs/mo2filesystem.h1
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
{