2011 lines
76 KiB
Python
2011 lines
76 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Neon Mod Manager — Cyberpunk 2077 Mod Manager
|
||
==============================================
|
||
Simple, no-nonsense mod manager that:
|
||
- Copies mods directly to archive/pc/mod/ (no VFS, no FUSE, no REDmod)
|
||
- Manages load order via filename prefixes (000_, 010_, 020_)
|
||
- Enable/disable mods by renaming .archive <-> .archive.disabled
|
||
- Parses .archive files to detect real file conflicts
|
||
- Supports REDmod script mods (r6/tweaks/red4ext)
|
||
- Auto-extracts .7z / .zip downloads and installs contents
|
||
- Catches nxm:// downloads from Nexus Mods
|
||
- Supports profiles (save/load mod sets)
|
||
|
||
Usage:
|
||
python3 mod_manager.py
|
||
→ GUI opens
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import shutil
|
||
import struct
|
||
import hashlib
|
||
import subprocess
|
||
import urllib.request
|
||
import urllib.parse
|
||
import zipfile
|
||
import tarfile
|
||
import io
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||
QListWidget, QListWidgetItem, QLabel, QPushButton, QFileDialog,
|
||
QTabWidget, QTreeWidget, QTreeWidgetItem, QComboBox, QLineEdit,
|
||
QMessageBox, QProgressBar, QGroupBox, QCheckBox, QSpinBox, QSlider,
|
||
QMenu, QStatusBar, QSplitter, QHeaderView, QTextEdit
|
||
)
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
|
||
from PyQt6.QtGui import QIcon, QAction, QColor, QFont, QDragEnterEvent, QDropEvent
|
||
|
||
# --- Config ---
|
||
APP_NAME = "Neon Mod Manager"
|
||
APP_VERSION = "0.2.0"
|
||
CONFIG_DIR = Path.home() / ".config" / "neon-mod-manager"
|
||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||
PROFILES_DIR = CONFIG_DIR / "profiles"
|
||
|
||
# Default game path — can be overridden in settings
|
||
DEFAULT_GAME_PATH = "/mnt/Spiele/Heroic/Cyberpunk 2077"
|
||
DEFAULT_MOD_DIR = "archive/pc/mod"
|
||
|
||
# Nexus Mods
|
||
NEXUS_API_KEY_FILE = Path.home() / ".nexus_api_key"
|
||
NEXUS_GAME_ID = "cyberpunk2077"
|
||
NEXUS_API_BASE = "https://api.nexusmods.com/v1"
|
||
|
||
|
||
# ===========================================================================
|
||
# ARCHIVE PARSING — Read internal file list from .archive files
|
||
# ===========================================================================
|
||
|
||
def read_archive_info(path):
|
||
"""Read info from a .archive file including internal file paths.
|
||
|
||
REDengine .archive format (RDAR):
|
||
Offset 0: magic "RDAR" (4 bytes)
|
||
Offset 4: version (4 bytes, uint32 LE)
|
||
Offset 8: file table offset (8 bytes, uint64 LE)
|
||
Offset 16: file count (4 bytes, uint32 LE)
|
||
|
||
The file table contains entries with:
|
||
- hash (8 bytes)
|
||
- offset (8 bytes)
|
||
- size (8 bytes)
|
||
- path string (variable, null-terminated)
|
||
|
||
The path strings are at the end of the file table, encoded as UTF-8.
|
||
"""
|
||
try:
|
||
with open(path, "rb") as f:
|
||
magic = f.read(4)
|
||
if magic != b"RDAR":
|
||
return {"valid": False, "error": "Not a RDAR archive", "files": [], "size": os.path.getsize(path)}
|
||
|
||
version = struct.unpack("<I", f.read(4))[0]
|
||
file_table_offset = struct.unpack("<Q", f.read(8))[0]
|
||
file_count = struct.unpack("<I", f.read(4))[0]
|
||
|
||
file_size = os.path.getsize(path)
|
||
files = []
|
||
|
||
if file_table_offset > 0 and file_table_offset < file_size and file_count > 0 and file_count < 100000:
|
||
try:
|
||
f.seek(file_table_offset)
|
||
|
||
# Each table entry: hash(8) + offset(8) + size(8) = 24 bytes + path
|
||
# The path strings follow the fixed-size entries
|
||
# Try to read entries
|
||
entry_size = 24 # hash + offset + size
|
||
entries_data = f.read(entry_size * file_count)
|
||
|
||
if len(entries_data) == entry_size * file_count:
|
||
# Try to read path strings after the entries
|
||
# Paths are null-terminated UTF-8 strings
|
||
paths_data = f.read(min(1024 * 1024, file_size - f.tell())) # Read up to 1MB of paths
|
||
|
||
# Parse null-terminated strings
|
||
all_paths = paths_data.split(b'\x00')
|
||
for p in all_paths:
|
||
try:
|
||
decoded = p.decode('utf-8', errors='ignore').strip()
|
||
if decoded and len(decoded) > 3 and ('/' in decoded or '\\' in decoded):
|
||
files.append(decoded)
|
||
except:
|
||
continue
|
||
|
||
# Limit to file_count
|
||
files = files[:file_count]
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"valid": True,
|
||
"version": version,
|
||
"file_count": file_count,
|
||
"table_offset": file_table_offset,
|
||
"files": files,
|
||
"size": file_size,
|
||
}
|
||
except Exception as e:
|
||
return {"valid": False, "error": str(e), "files": [], "size": 0}
|
||
|
||
|
||
def get_file_hash(path, chunk_size=65536):
|
||
"""Get SHA256 hash of a file for duplicate detection."""
|
||
h = hashlib.sha256()
|
||
with open(path, "rb") as f:
|
||
while True:
|
||
chunk = f.read(chunk_size)
|
||
if not chunk:
|
||
break
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
|
||
# ===========================================================================
|
||
# ARCHIVE CONFLICT DETECTION — Compare internal file paths
|
||
# ===========================================================================
|
||
|
||
def scan_archive_conflicts(mods, progress_callback=None):
|
||
"""Scan all enabled .archive mods and find files that appear in multiple mods.
|
||
|
||
Returns a list of conflicts: [{file, mods: [mod1, mod2, ...]}, ...]
|
||
"""
|
||
file_map = {} # file_path -> [mod_name, ...]
|
||
|
||
for i, mod in enumerate(mods):
|
||
if not mod.enabled:
|
||
continue
|
||
if progress_callback:
|
||
progress_callback(i, len(mods), f"Scanning {mod.name}...")
|
||
|
||
info = mod.info if mod.info else read_archive_info(mod.path)
|
||
mod.info = info
|
||
|
||
for internal_file in info.get("files", []):
|
||
# Normalize path
|
||
norm = internal_file.lower().replace("\\", "/")
|
||
if norm not in file_map:
|
||
file_map[norm] = []
|
||
file_map[norm].append(mod.name)
|
||
|
||
# Find conflicts (files in more than one mod)
|
||
conflicts = []
|
||
for file_path, mod_names in file_map.items():
|
||
if len(mod_names) > 1:
|
||
conflicts.append({
|
||
"file": file_path,
|
||
"mods": list(set(mod_names)),
|
||
"count": len(set(mod_names)),
|
||
})
|
||
|
||
return conflicts
|
||
|
||
|
||
# ===========================================================================
|
||
# ARCHIVE EXTRACTION — Auto-extract .7z / .zip / .rar downloads
|
||
# ===========================================================================
|
||
|
||
# Mod installation structure:
|
||
# .archive files → archive/pc/mod/
|
||
# .reds files → r6/scripts/
|
||
# .yaml files (TweakXL) → r6/tweaks/
|
||
# .dll files (red4ext plugins) → red4ext/plugins/<modname>/
|
||
# .ini files (engine config) → engine/config/
|
||
# .xl files (ArchiveXL) → same dir as .archive
|
||
|
||
ARCHIVE_EXTS = {".archive"}
|
||
REDS_DIRS = {"r6", "scripts", "red4ext", "engine", "bin"}
|
||
TWEAK_EXTS = {".yaml", ".yml"}
|
||
DLL_EXTS = {".dll"}
|
||
|
||
|
||
# ===========================================================================
|
||
# REDSCRIPT COMPILER — Compile r6/scripts/ via Wine
|
||
# ===========================================================================
|
||
|
||
def find_scc_exe():
|
||
"""Find the redscript compiler (scc.exe)."""
|
||
# Check Fluorine mods first
|
||
fluorine_scc = Path.home() / ".local/share/fluorine/Cyberpunk 2077/mods/redscript/engine/tools/scc.exe"
|
||
if fluorine_scc.exists():
|
||
return fluorine_scc
|
||
|
||
# Check game directory
|
||
game_scc = Path("/mnt/Spiele/Heroic/Cyberpunk 2077/engine/tools/scc.exe")
|
||
if game_scc.exists():
|
||
return game_scc
|
||
|
||
return None
|
||
|
||
|
||
def find_wine_prefix():
|
||
"""Find a working Wine prefix."""
|
||
prefixes = [
|
||
Path.home() / ".local/share/fluorine/Prefix/pfx",
|
||
Path.home() / ".wine",
|
||
]
|
||
for p in prefixes:
|
||
if p.exists():
|
||
return p
|
||
return None
|
||
|
||
|
||
def compile_redscript(game_path, progress_callback=None):
|
||
"""Run the redscript compiler (scc.exe) via Wine.
|
||
|
||
Compiles all .reds files in r6/scripts/ into r6/cache/final.redscripts.
|
||
Returns (success, output_text).
|
||
"""
|
||
game_path = Path(game_path)
|
||
scc_exe = find_scc_exe()
|
||
|
||
if not scc_exe:
|
||
return False, "redscript compiler (scc.exe) not found. Install the 'redscript' mod first."
|
||
|
||
wine_prefix = find_wine_prefix()
|
||
if not wine_prefix:
|
||
return False, "No Wine prefix found. Install Wine/Proton first."
|
||
|
||
# Ensure r6/scripts exists
|
||
scripts_dir = game_path / "r6/scripts"
|
||
if not scripts_dir.exists():
|
||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Ensure r6/cache exists
|
||
cache_dir = game_path / "r6/cache"
|
||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
if progress_callback:
|
||
progress_callback("Compiling redscript...")
|
||
|
||
import subprocess
|
||
import os
|
||
|
||
env = os.environ.copy()
|
||
env["WINEPREFIX"] = str(wine_prefix)
|
||
env["WINEDEBUG"] = "-all"
|
||
env["WINEARCH"] = "win64"
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
["wine", str(scc_exe), "-compile", "r6/scripts"],
|
||
cwd=str(game_path),
|
||
env=env,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=120
|
||
)
|
||
|
||
output = result.stdout + result.stderr
|
||
success = "Output successfully saved" in output or "Compilation complete" in output
|
||
|
||
if success:
|
||
if progress_callback:
|
||
progress_callback("✅ redscript compiled successfully!")
|
||
return True, output
|
||
else:
|
||
if progress_callback:
|
||
progress_callback("❌ redscript compilation failed!")
|
||
return False, output
|
||
|
||
except subprocess.TimeoutExpired:
|
||
return False, "Compilation timed out (120s)"
|
||
except Exception as e:
|
||
return False, f"Error: {e}"
|
||
|
||
|
||
def install_mod_from_folder(mod_folder, game_path, progress_callback=None):
|
||
"""Install a mod from a folder to the game directory.
|
||
|
||
Handles all file types:
|
||
- .archive → archive/pc/mod/
|
||
- .xl → archive/pc/mod/
|
||
- .reds → r6/scripts/<modname>/ (preserving structure)
|
||
- .yaml → r6/tweaks/<modname>/ (preserving structure)
|
||
- .lua → bin/x64/plugins/cyber_engine_tweaks/mods/<modname>/
|
||
- .dll → red4ext/plugins/<modname>/
|
||
- .ini → engine/config/
|
||
- Scripts under red4ext/plugins/*/Scripts/ → also symlinked to r6/scripts/
|
||
"""
|
||
mod_folder = Path(mod_folder)
|
||
game_path = Path(game_path)
|
||
mod_name = mod_folder.name
|
||
|
||
installed = {"archive": 0, "xl": 0, "reds": 0, "yaml": 0, "lua": 0, "dll": 0, "ini": 0, "other": 0}
|
||
errors = []
|
||
|
||
# Ensure dirs exist
|
||
for d in ["archive/pc/mod", "r6/scripts", "r6/tweaks", "engine/config",
|
||
"bin/x64/plugins/cyber_engine_tweaks/mods", "red4ext/plugins"]:
|
||
(game_path / d).mkdir(parents=True, exist_ok=True)
|
||
|
||
for root, dirs, files in os.walk(mod_folder):
|
||
for f in files:
|
||
src = Path(root) / f
|
||
ext = src.suffix.lower()
|
||
rel_path = src.relative_to(mod_folder)
|
||
|
||
try:
|
||
if ext == ".archive":
|
||
dst = game_path / "archive/pc/mod" / f
|
||
shutil.copy2(src, dst)
|
||
installed["archive"] += 1
|
||
|
||
elif ext == ".xl":
|
||
dst = game_path / "archive/pc/mod" / f
|
||
shutil.copy2(src, dst)
|
||
installed["xl"] += 1
|
||
|
||
elif ext == ".reds":
|
||
# Check if under red4ext/plugins/*/Scripts/ — copy to BOTH locations
|
||
parts = rel_path.parts
|
||
if "red4ext" in parts and "Scripts" in parts:
|
||
# Copy to original red4ext location
|
||
dst = game_path / rel_path
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
# ALSO copy to r6/scripts/<modname>/ for compiler
|
||
script_dst = game_path / "r6/scripts" / mod_name / f
|
||
script_dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, script_dst)
|
||
else:
|
||
# Normal redscript — preserve structure
|
||
dst = game_path / "r6/scripts" / mod_name / str(rel_path)
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed["reds"] += 1
|
||
|
||
elif ext in [".yaml", ".yml"]:
|
||
dst = game_path / "r6/tweaks" / mod_name / str(rel_path)
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed["yaml"] += 1
|
||
|
||
elif ext == ".lua":
|
||
dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / mod_name / str(rel_path)
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed["lua"] += 1
|
||
|
||
elif ext == ".dll":
|
||
# red4ext plugins — preserve structure
|
||
dst = game_path / str(rel_path)
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed["dll"] += 1
|
||
|
||
elif ext == ".ini":
|
||
if "config" in str(root).lower() or "engine" in str(root).lower():
|
||
dst = game_path / "engine/config" / f
|
||
else:
|
||
dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / mod_name / str(rel_path)
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed["ini"] += 1
|
||
|
||
elif ext in [".json", ".toml", ".txt", ".mp3"]:
|
||
# Preserve structure for data files
|
||
dst = game_path / str(rel_path)
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed["other"] += 1
|
||
|
||
except Exception as e:
|
||
errors.append(f"{rel_path}: {e}")
|
||
|
||
return installed, errors
|
||
|
||
|
||
def extract_archive(filepath, extract_to):
|
||
"""Extract a .7z/.zip/.rar file to a temporary directory.
|
||
|
||
Returns the path to the extracted directory, or None on failure.
|
||
"""
|
||
filepath = Path(filepath)
|
||
extract_dir = extract_to / filepath.stem
|
||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
ext = filepath.suffix.lower()
|
||
|
||
if ext == ".zip":
|
||
try:
|
||
with zipfile.ZipFile(filepath, 'r') as zf:
|
||
zf.extractall(extract_dir)
|
||
return extract_dir
|
||
except Exception as e:
|
||
print(f"ZIP extraction failed: {e}")
|
||
return None
|
||
|
||
elif ext == ".7z":
|
||
# Try 7z command
|
||
try:
|
||
result = subprocess.run(
|
||
["7z", "x", str(filepath), f"-o{extract_dir}", "-y"],
|
||
capture_output=True, text=True, timeout=120
|
||
)
|
||
if result.returncode == 0:
|
||
return extract_dir
|
||
print(f"7z failed: {result.stderr}")
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
# Try 7za (p7zip-full)
|
||
try:
|
||
result = subprocess.run(
|
||
["7za", "x", str(filepath), f"-o{extract_dir}", "-y"],
|
||
capture_output=True, text=True, timeout=120
|
||
)
|
||
if result.returncode == 0:
|
||
return extract_dir
|
||
print(f"7za failed: {result.stderr}")
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
print("7z not found. Install p7zip: sudo pacman -S p7zip")
|
||
return None
|
||
|
||
elif ext in (".rar", ".r00"):
|
||
try:
|
||
result = subprocess.run(
|
||
["unrar", "x", str(filepath), str(extract_dir) + "/", "-y"],
|
||
capture_output=True, text=True, timeout=120
|
||
)
|
||
if result.returncode == 0:
|
||
return extract_dir
|
||
print(f"unrar failed: {result.stderr}")
|
||
except FileNotFoundError:
|
||
pass
|
||
print("unrar not found. Install unrar: sudo pacman -S unrar")
|
||
return None
|
||
|
||
elif ext in (".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz"):
|
||
try:
|
||
with tarfile.open(filepath, 'r:*') as tf:
|
||
tf.extractall(extract_dir)
|
||
return extract_dir
|
||
except Exception as e:
|
||
print(f"tar extraction failed: {e}")
|
||
return None
|
||
|
||
return None
|
||
|
||
|
||
def install_extracted_mod(extract_dir, game_path):
|
||
"""Install files from an extracted mod directory to the correct game paths.
|
||
|
||
Returns a list of installed files.
|
||
"""
|
||
game_path = Path(game_path)
|
||
mod_dir = game_path / "archive/pc/mod"
|
||
installed = []
|
||
|
||
# Walk the extracted directory
|
||
for root, dirs, files in os.walk(extract_dir):
|
||
root_path = Path(root)
|
||
|
||
for fname in files:
|
||
src = root_path / fname
|
||
ext = Path(fname).suffix.lower()
|
||
|
||
# .archive files → archive/pc/mod/
|
||
if ext == ".archive":
|
||
dst = mod_dir / fname
|
||
mod_dir.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed.append(f"archive/pc/mod/{fname}")
|
||
|
||
# .xl files (ArchiveXL) → same dir as .archive
|
||
elif ext == ".xl":
|
||
dst = mod_dir / fname
|
||
mod_dir.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed.append(f"archive/pc/mod/{fname}")
|
||
|
||
# .yaml/.yml (TweakXL) → r6/tweaks/
|
||
elif ext in (".yaml", ".yml"):
|
||
# Check if it's in a tweaks folder already
|
||
rel = root_path.relative_to(extract_dir)
|
||
parts = rel.parts
|
||
|
||
if "tweaks" in parts or "r6" in parts:
|
||
# Preserve relative path under r6/
|
||
if "r6" in parts:
|
||
idx = parts.index("r6")
|
||
rel_under_r6 = Path(*parts[idx+1:])
|
||
dst = game_path / "r6" / rel_under_r6 / fname
|
||
else:
|
||
dst = game_path / "r6" / "tweaks" / fname
|
||
else:
|
||
dst = game_path / "r6" / "tweaks" / fname
|
||
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed.append(str(dst.relative_to(game_path)))
|
||
|
||
# .reds (redscript) → r6/scripts/
|
||
elif ext == ".reds":
|
||
rel = root_path.relative_to(extract_dir)
|
||
parts = rel.parts
|
||
|
||
if "r6" in parts:
|
||
idx = parts.index("r6")
|
||
rel_under_r6 = Path(*parts[idx+1:])
|
||
dst = game_path / "r6" / rel_under_r6 / fname
|
||
elif "scripts" in parts:
|
||
dst = game_path / "r6" / "scripts" / fname
|
||
else:
|
||
dst = game_path / "r6" / "scripts" / fname
|
||
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed.append(str(dst.relative_to(game_path)))
|
||
|
||
# .dll (red4ext plugins) → red4ext/plugins/<modname>/
|
||
elif ext == ".dll":
|
||
rel = root_path.relative_to(extract_dir)
|
||
parts = rel.parts
|
||
|
||
if "red4ext" in parts and "plugins" in parts:
|
||
# Preserve structure
|
||
dst = game_path / rel / fname
|
||
elif "plugins" in parts:
|
||
idx = parts.index("plugins")
|
||
plugin_name = parts[idx + 1] if idx + 1 < len(parts) else extract_dir.name
|
||
dst = game_path / "red4ext" / "plugins" / plugin_name / fname
|
||
else:
|
||
plugin_name = extract_dir.name
|
||
dst = game_path / "red4ext" / "plugins" / plugin_name / fname
|
||
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed.append(str(dst.relative_to(game_path)))
|
||
|
||
# .ini (engine config) → engine/config/
|
||
elif ext == ".ini":
|
||
rel = root_path.relative_to(extract_dir)
|
||
parts = rel.parts
|
||
|
||
if "engine" in parts:
|
||
dst = game_path / rel / fname
|
||
else:
|
||
dst = game_path / "engine" / "config" / fname
|
||
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed.append(str(dst.relative_to(game_path)))
|
||
|
||
# CET mods (cyber_engine_tweaks) → bin/x64/plugins/cyber_engine_tweaks/mods/
|
||
elif fname.endswith(".lua") or "cyber_engine_tweaks" in str(root_path).lower():
|
||
rel = root_path.relative_to(extract_dir)
|
||
parts = rel.parts
|
||
|
||
if "cyber_engine_tweaks" in str(rel).lower() and "mods" in parts:
|
||
idx = parts.index("mods")
|
||
if idx + 1 < len(parts):
|
||
mod_name = parts[idx + 1]
|
||
dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / mod_name / fname
|
||
else:
|
||
dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / fname
|
||
elif ext == ".lua":
|
||
dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / extract_dir.name / fname
|
||
else:
|
||
continue # Skip non-lua files in CET context
|
||
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, dst)
|
||
installed.append(str(dst.relative_to(game_path)))
|
||
|
||
return installed
|
||
|
||
|
||
def install_mod_file(filepath, game_path, status_callback=None):
|
||
"""Install a mod file — auto-detect type and handle accordingly.
|
||
|
||
Supports: .archive, .zip, .7z, .rar, .tar.gz
|
||
"""
|
||
filepath = Path(filepath)
|
||
game_path = Path(game_path)
|
||
ext = filepath.suffix.lower()
|
||
|
||
if ext == ".archive" or ext == ".xl":
|
||
# Direct copy to mod directory
|
||
mod_dir = game_path / "archive/pc/mod"
|
||
mod_dir.mkdir(parents=True, exist_ok=True)
|
||
dst = mod_dir / filepath.name
|
||
if status_callback:
|
||
status_callback(f"Installing {filepath.name}...")
|
||
shutil.copy2(filepath, dst)
|
||
return [f"archive/pc/mod/{filepath.name}"]
|
||
|
||
elif ext in (".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".r00"):
|
||
# Extract and install
|
||
if status_callback:
|
||
status_callback(f"Extracting {filepath.name}...")
|
||
|
||
extract_dir = extract_archive(filepath, Path("/tmp/neon-mod-extract"))
|
||
if not extract_dir:
|
||
return None
|
||
|
||
if status_callback:
|
||
status_callback(f"Installing files from {filepath.name}...")
|
||
|
||
installed = install_extracted_mod(extract_dir, game_path)
|
||
|
||
# Cleanup
|
||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||
|
||
return installed
|
||
|
||
elif ext in (".yaml", ".yml"):
|
||
# TweakXL — copy to r6/tweaks/
|
||
dst = game_path / "r6/tweaks" / filepath.name
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(filepath, dst)
|
||
return [f"r6/tweaks/{filepath.name}"]
|
||
|
||
elif ext == ".reds":
|
||
# redscript — copy to r6/scripts/
|
||
dst = game_path / "r6/scripts" / filepath.name
|
||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(filepath, dst)
|
||
return [f"r6/scripts/{filepath.name}"]
|
||
|
||
else:
|
||
return None
|
||
|
||
|
||
# ===========================================================================
|
||
# REDMOD SCRIPT SUPPORT — Manage r6/tweaks, r6/scripts, red4ext
|
||
# ===========================================================================
|
||
|
||
def scan_script_mods(game_path):
|
||
"""Scan for script-based mods (redscript, TweakXL, CET, red4ext).
|
||
|
||
Returns dict with categories and their files.
|
||
"""
|
||
game_path = Path(game_path)
|
||
script_mods = {
|
||
"redscript": [], # r6/scripts/*.reds
|
||
"tweakxl": [], # r6/tweaks/*.yaml
|
||
"cet": [], # bin/x64/plugins/cyber_engine_tweaks/mods/*
|
||
"red4ext": [], # red4ext/plugins/*
|
||
"archivexl": [], # *.xl files in archive/pc/mod/
|
||
}
|
||
|
||
# redscript
|
||
rs_dir = game_path / "r6/scripts"
|
||
if rs_dir.exists():
|
||
for f in rs_dir.rglob("*.reds"):
|
||
script_mods["redscript"].append(str(f.relative_to(game_path)))
|
||
|
||
# TweakXL
|
||
tw_dir = game_path / "r6/tweaks"
|
||
if tw_dir.exists():
|
||
for f in tw_dir.rglob("*.yaml"):
|
||
script_mods["tweakxl"].append(str(f.relative_to(game_path)))
|
||
for f in tw_dir.rglob("*.yml"):
|
||
script_mods["tweakxl"].append(str(f.relative_to(game_path)))
|
||
|
||
# CET
|
||
cet_dir = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods"
|
||
if cet_dir.exists():
|
||
for d in cet_dir.iterdir():
|
||
if d.is_dir():
|
||
files = list(d.rglob("*"))
|
||
if files:
|
||
script_mods["cet"].append(str(d.relative_to(game_path)))
|
||
|
||
# red4ext
|
||
r4e_dir = game_path / "red4ext/plugins"
|
||
if r4e_dir.exists():
|
||
for d in r4e_dir.iterdir():
|
||
if d.is_dir():
|
||
files = list(d.glob("*.dll"))
|
||
if files:
|
||
script_mods["red4ext"].append(str(d.relative_to(game_path)))
|
||
|
||
# ArchiveXL
|
||
mod_dir = game_path / "archive/pc/mod"
|
||
if mod_dir.exists():
|
||
for f in mod_dir.glob("*.xl"):
|
||
script_mods["archivexl"].append(str(f.relative_to(game_path)))
|
||
|
||
return script_mods
|
||
|
||
|
||
# ===========================================================================
|
||
# CONFIG MANAGEMENT
|
||
# ===========================================================================
|
||
|
||
def load_config():
|
||
"""Load configuration from file."""
|
||
if CONFIG_FILE.exists():
|
||
with open(CONFIG_FILE, "r") as f:
|
||
return json.load(f)
|
||
return {
|
||
"game_path": DEFAULT_GAME_PATH,
|
||
"mod_dir": DEFAULT_MOD_DIR,
|
||
"last_profile": "Default",
|
||
"nexus_api_key": "",
|
||
"auto_prefix": True,
|
||
}
|
||
|
||
|
||
def save_config(config):
|
||
"""Save configuration to file."""
|
||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
with open(CONFIG_FILE, "w") as f:
|
||
json.dump(config, f, indent=2)
|
||
|
||
|
||
# ===========================================================================
|
||
# MOD CLASS
|
||
# ===========================================================================
|
||
|
||
class Mod:
|
||
"""Represents a single mod file."""
|
||
def __init__(self, path, enabled=True, prefix=""):
|
||
self.path = Path(path)
|
||
self.enabled = enabled
|
||
self.prefix = prefix
|
||
self.info = {}
|
||
self.conflicts = []
|
||
|
||
@property
|
||
def name(self):
|
||
name = self.path.name
|
||
if name.endswith(".disabled"):
|
||
name = name[:-9]
|
||
if name.endswith(".archive"):
|
||
name = name[:-8]
|
||
if len(name) > 4 and name[:4].isdigit() and name[3] == "_":
|
||
name = name[4:]
|
||
return name
|
||
|
||
@property
|
||
def filename(self):
|
||
return self.path.name
|
||
|
||
@property
|
||
def size_mb(self):
|
||
return self.path.stat().st_size / (1024 * 1024)
|
||
|
||
@property
|
||
def sort_key(self):
|
||
name = self.path.name
|
||
if name[:3].isdigit():
|
||
return name[:3]
|
||
return "999"
|
||
|
||
|
||
def scan_mods(mod_dir):
|
||
"""Scan the mod directory and return list of Mod objects."""
|
||
mods = []
|
||
mod_path = Path(mod_dir)
|
||
if not mod_path.exists():
|
||
return mods
|
||
|
||
for f in mod_path.iterdir():
|
||
if f.is_file() and (f.name.endswith(".archive") or f.name.endswith(".archive.disabled")):
|
||
enabled = f.name.endswith(".archive")
|
||
mod = Mod(f, enabled=enabled)
|
||
mods.append(mod)
|
||
|
||
mods.sort(key=lambda m: m.sort_key)
|
||
return mods
|
||
|
||
|
||
def toggle_mod(mod):
|
||
"""Enable or disable a mod by renaming .archive <-> .archive.disabled."""
|
||
if mod.enabled:
|
||
new_path = mod.path.with_suffix(mod.path.suffix + ".disabled")
|
||
mod.path.rename(new_path)
|
||
mod.path = new_path
|
||
mod.enabled = False
|
||
else:
|
||
new_path = mod.path.with_suffix("")
|
||
mod.path.rename(new_path)
|
||
mod.path = new_path
|
||
mod.enabled = True
|
||
|
||
|
||
def set_load_order(mods, order_list):
|
||
"""Rename mods to set load order via numeric prefixes."""
|
||
for i, mod_name in enumerate(order_list):
|
||
prefix = f"{i * 10:03d}_"
|
||
for mod in mods:
|
||
if mod.name == mod_name:
|
||
old_name = mod.path.name
|
||
clean_name = old_name
|
||
if clean_name[:4].isdigit() and clean_name[3] == "_":
|
||
clean_name = clean_name[4:]
|
||
new_name = prefix + clean_name
|
||
if old_name != new_name:
|
||
new_path = mod.path.parent / new_name
|
||
mod.path.rename(new_path)
|
||
mod.path = new_path
|
||
break
|
||
|
||
|
||
# ===========================================================================
|
||
# NEXUS MODS INTEGRATION
|
||
# ===========================================================================
|
||
|
||
def parse_nxm_url(url):
|
||
"""Parse an nxm:// URL to extract mod info."""
|
||
parsed = urllib.parse.urlparse(url)
|
||
parts = parsed.path.strip("/").split("/")
|
||
if len(parts) >= 4 and parts[0] == "mods" and parts[2] == "files":
|
||
return {
|
||
"game": parsed.netloc,
|
||
"mod_id": parts[1],
|
||
"file_id": parts[3],
|
||
"params": dict(urllib.parse.parse_qsl(parsed.query)),
|
||
}
|
||
return None
|
||
|
||
|
||
def download_nxm_mod(nxm_url, api_key, download_dir):
|
||
"""Download a mod from Nexus Mods using an nxm:// URL."""
|
||
info = parse_nxm_url(nxm_url)
|
||
if not info:
|
||
return None, "Invalid NXM URL"
|
||
|
||
headers = {"apikey": api_key}
|
||
api_url = f"{NEXUS_API_BASE}/games/{info['game']}/mods/{info['mod_id']}/files/{info['file_id']}/download_link.json"
|
||
|
||
try:
|
||
req = urllib.request.Request(api_url, headers=headers)
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
data = json.loads(resp.read())
|
||
download_url = data[0]["URI"]
|
||
|
||
filename = f"mod_{info['mod_id']}_{info['file_id']}.7z"
|
||
filepath = Path(download_dir) / filename
|
||
|
||
urllib.request.urlretrieve(download_url, filepath)
|
||
return filepath, None
|
||
except Exception as e:
|
||
return None, str(e)
|
||
|
||
|
||
# ===========================================================================
|
||
# GUI
|
||
# ===========================================================================
|
||
|
||
class ModManagerWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.config = load_config()
|
||
self.mods = []
|
||
self.current_profile = self.config.get("last_profile", "Default")
|
||
self.script_mods = {}
|
||
|
||
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
|
||
self.setMinimumSize(900, 600)
|
||
self.setStyleSheet(self._get_style())
|
||
self.setAcceptDrops(True)
|
||
|
||
# Central widget
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
layout = QVBoxLayout(central)
|
||
|
||
# Top bar
|
||
top_bar = self._create_top_bar()
|
||
layout.addLayout(top_bar)
|
||
|
||
# Tabs
|
||
self.tabs = QTabWidget()
|
||
self.tabs.addTab(self._create_mods_tab(), "📦 Mods")
|
||
self.tabs.addTab(self._create_scripts_tab(), "📜 Script Mods")
|
||
self.tabs.addTab(self._create_conflicts_tab(), "⚠️ Konflikte")
|
||
self.tabs.addTab(self._create_profiles_tab(), "💾 Profile")
|
||
self.tabs.addTab(self._create_nexus_tab(), "🌐 Nexus Mods")
|
||
self.tabs.addTab(self._create_settings_tab(), "⚙️ Settings")
|
||
layout.addWidget(self.tabs)
|
||
|
||
# Status bar
|
||
self.status = QStatusBar()
|
||
self.setStatusBar(self.status)
|
||
self.status.showMessage("Ready")
|
||
|
||
# Load on startup
|
||
QTimer.singleShot(100, self.refresh_all)
|
||
|
||
def _get_style(self):
|
||
return """
|
||
QMainWindow { background: #0a0a0f; }
|
||
QWidget { background: #12121a; color: #e0e0e0; font-size: 14px; }
|
||
QTabWidget::pane { border: 1px solid #2a2a3a; border-radius: 8px; }
|
||
QTabBar::tab { background: #12121a; border: 1px solid #2a2a3a; padding: 8px 20px; border-radius: 8px 8px 0 0; color: #a0a0b0; }
|
||
QTabBar::tab:selected { background: #1a1a2a; border-bottom: 2px solid #00ff9f; color: #00ff9f; }
|
||
QPushButton { background: #1a1a2a; border: 1px solid #2a2a3a; border-radius: 6px; padding: 8px 16px; color: #e0e0e0; }
|
||
QPushButton:hover { background: #2a2a3a; border-color: #00ff9f; }
|
||
QPushButton:pressed { background: #00ff9f; color: #0a0a0f; }
|
||
QListWidget { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; }
|
||
QListWidgetItem { padding: 4px; }
|
||
QTreeWidget { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; }
|
||
QLineEdit, QComboBox { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 4px; padding: 6px; }
|
||
QGroupBox { border: 1px solid #2a2a3a; border-radius: 8px; margin-top: 12px; padding-top: 12px; }
|
||
QGroupBox::title { color: #00ff9f; subcontrol-origin: margin; left: 12px; padding: 0 4px; }
|
||
QProgressBar { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 4px; text-align: center; }
|
||
QProgressBar::chunk { background: #00ff9f; border-radius: 4px; }
|
||
QCheckBox { color: #e0e0e0; }
|
||
QLabel { color: #e0e0e0; }
|
||
QStatusBar { background: #12121a; border-top: 1px solid #2a2a3a; }
|
||
QTextEdit { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; }
|
||
"""
|
||
|
||
# --- Drag & Drop ---
|
||
|
||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
|
||
def dropEvent(self, event: QDropEvent):
|
||
for url in event.mimeData().urls():
|
||
filepath = url.toLocalFile()
|
||
if not filepath:
|
||
continue
|
||
|
||
path = Path(filepath)
|
||
if path.is_dir():
|
||
# Dropped a folder — install as mod from folder
|
||
installed, errors = install_mod_from_folder(path, self.config["game_path"])
|
||
total = sum(installed.values())
|
||
self.status.showMessage(f"Installed {total} files from {path.name}")
|
||
else:
|
||
self.install_mod(filepath)
|
||
|
||
# --- Top bar ---
|
||
|
||
def _create_top_bar(self):
|
||
layout = QHBoxLayout()
|
||
|
||
layout.addWidget(QLabel("Game:"))
|
||
self.game_path_label = QLabel(self.config.get("game_path", DEFAULT_GAME_PATH))
|
||
self.game_path_label.setStyleSheet("color: #a0a0b0; padding: 0 10px;")
|
||
layout.addWidget(self.game_path_label, 1)
|
||
|
||
browse_btn = QPushButton("📂 Browse")
|
||
browse_btn.clicked.connect(self.browse_game_path)
|
||
layout.addWidget(browse_btn)
|
||
|
||
layout.addWidget(QLabel(" | "))
|
||
|
||
layout.addWidget(QLabel("Profile:"))
|
||
self.profile_combo = QComboBox()
|
||
self.profile_combo.setMinimumWidth(150)
|
||
self._refresh_profiles()
|
||
self.profile_combo.currentTextChanged.connect(self.load_profile)
|
||
layout.addWidget(self.profile_combo)
|
||
|
||
refresh_btn = QPushButton("🔄 Refresh")
|
||
refresh_btn.clicked.connect(self.refresh_all)
|
||
layout.addWidget(refresh_btn)
|
||
|
||
return layout
|
||
|
||
# --- Mods tab ---
|
||
|
||
def _create_mods_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
|
||
self.mod_list = QTreeWidget()
|
||
self.mod_list.setHeaderLabels(["", "Mod Name", "Size", "Order", "Status", "Files"])
|
||
self.mod_list.setRootIsDecorated(False)
|
||
self.mod_list.setAlternatingRowColors(True)
|
||
self.mod_list.setColumnWidth(0, 40)
|
||
self.mod_list.setColumnWidth(1, 250)
|
||
self.mod_list.setColumnWidth(2, 80)
|
||
self.mod_list.setColumnWidth(3, 60)
|
||
self.mod_list.setColumnWidth(5, 60)
|
||
self.mod_list.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
self.mod_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
self.mod_list.customContextMenuRequested.connect(self._mod_context_menu)
|
||
self.mod_list.itemChanged.connect(self._on_mod_checkbox_changed)
|
||
|
||
splitter.addWidget(self.mod_list)
|
||
|
||
# Details panel
|
||
details_group = QGroupBox("Details")
|
||
details_layout = QVBoxLayout(details_group)
|
||
self.details_label = QTextEdit()
|
||
self.details_label.setReadOnly(True)
|
||
self.details_label.setMaximumHeight(300)
|
||
details_layout.addWidget(self.details_label)
|
||
splitter.addWidget(details_group)
|
||
|
||
splitter.setSizes([600, 300])
|
||
layout.addWidget(splitter, 1)
|
||
|
||
# Buttons
|
||
btn_layout = QHBoxLayout()
|
||
|
||
add_btn = QPushButton("➕ Add Mod (.archive, .zip, .7z)")
|
||
add_btn.clicked.connect(self.add_mod)
|
||
btn_layout.addWidget(add_btn)
|
||
|
||
enable_all_btn = QPushButton("✅ Enable All")
|
||
enable_all_btn.clicked.connect(self.enable_all_mods)
|
||
btn_layout.addWidget(enable_all_btn)
|
||
|
||
disable_all_btn = QPushButton("❌ Disable All")
|
||
disable_all_btn.clicked.connect(self.disable_all_mods)
|
||
btn_layout.addWidget(disable_all_btn)
|
||
|
||
reorder_btn = QPushButton("🔢 Auto-Sort (Load Order)")
|
||
reorder_btn.clicked.connect(self.auto_sort_mods)
|
||
btn_layout.addWidget(reorder_btn)
|
||
|
||
btn_layout.addStretch()
|
||
layout.addLayout(btn_layout)
|
||
|
||
return widget
|
||
|
||
# --- Script Mods tab ---
|
||
|
||
def _create_scripts_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
layout.addWidget(QLabel("📜 Script-based mods (redscript, TweakXL, CET, red4ext, ArchiveXL):"))
|
||
|
||
self.script_tree = QTreeWidget()
|
||
self.script_tree.setHeaderLabels(["Type", "Mod Name", "Path", "Files", "Status"])
|
||
self.script_tree.setAlternatingRowColors(True)
|
||
header = self.script_tree.header()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||
self.script_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
self.script_tree.customContextMenuRequested.connect(self._script_context_menu)
|
||
layout.addWidget(self.script_tree, 1)
|
||
|
||
# Info label
|
||
self.script_info_label = QLabel("Ready. Click Refresh to scan.")
|
||
self.script_info_label.setStyleSheet("color: #888; font-size: 11px;")
|
||
layout.addWidget(self.script_info_label)
|
||
|
||
# Buttons
|
||
btn_layout = QHBoxLayout()
|
||
add_script_btn = QPushButton("➕ Add Script Mod (.yaml, .reds, .zip, .7z)")
|
||
add_script_btn.clicked.connect(self.add_script_mod)
|
||
btn_layout.addWidget(add_script_btn)
|
||
|
||
refresh_script_btn = QPushButton("🔄 Refresh")
|
||
refresh_script_btn.clicked.connect(self.refresh_script_mods)
|
||
btn_layout.addWidget(refresh_script_btn)
|
||
|
||
toggle_script_btn = QPushButton("🔄 Toggle Selected")
|
||
toggle_script_btn.clicked.connect(self._toggle_script_mod)
|
||
btn_layout.addWidget(toggle_script_btn)
|
||
|
||
install_folder_btn = QPushButton("📂 Install from Folder")
|
||
install_folder_btn.clicked.connect(self._install_from_folder)
|
||
btn_layout.addWidget(install_folder_btn)
|
||
|
||
compile_btn = QPushButton("🔨 Compile redscript")
|
||
compile_btn.clicked.connect(self._compile_redscript)
|
||
btn_layout.addWidget(compile_btn)
|
||
|
||
btn_layout.addStretch()
|
||
layout.addLayout(btn_layout)
|
||
|
||
return widget
|
||
|
||
# --- Conflicts tab ---
|
||
|
||
def _create_conflicts_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
layout.addWidget(QLabel("⚠️ Real file conflicts — files that appear in multiple .archive mods:"))
|
||
|
||
self.conflict_tree = QTreeWidget()
|
||
self.conflict_tree.setHeaderLabels(["Internal File Path", "Mod 1", "Mod 2", "Mod 3", "Count"])
|
||
self.conflict_tree.setAlternatingRowColors(True)
|
||
self.conflict_tree.setColumnWidth(0, 350)
|
||
layout.addWidget(self.conflict_tree, 1)
|
||
|
||
# Progress bar for scanning
|
||
self.conflict_progress = QProgressBar()
|
||
self.conflict_progress.setVisible(False)
|
||
layout.addWidget(self.conflict_progress)
|
||
|
||
btn_layout = QHBoxLayout()
|
||
scan_btn = QPushButton("🔍 Deep Scan (Parse All Archives)")
|
||
scan_btn.clicked.connect(self.scan_conflicts_deep)
|
||
btn_layout.addWidget(scan_btn)
|
||
|
||
quick_btn = QPushButton("⚡ Quick Scan (Names Only)")
|
||
quick_btn.clicked.connect(self.scan_conflicts_quick)
|
||
btn_layout.addWidget(quick_btn)
|
||
|
||
btn_layout.addStretch()
|
||
layout.addLayout(btn_layout)
|
||
|
||
return widget
|
||
|
||
# --- Profiles tab ---
|
||
|
||
def _create_profiles_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
layout.addWidget(QLabel("Save and load mod configurations as profiles:"))
|
||
|
||
name_layout = QHBoxLayout()
|
||
name_layout.addWidget(QLabel("Name:"))
|
||
self.profile_name_input = QLineEdit()
|
||
self.profile_name_input.setPlaceholderText("Profile name (e.g. NSFW, Vanilla+, Minimal)")
|
||
name_layout.addWidget(self.profile_name_input, 1)
|
||
save_btn = QPushButton("💾 Save Profile")
|
||
save_btn.clicked.connect(self.save_profile)
|
||
name_layout.addWidget(save_btn)
|
||
layout.addLayout(name_layout)
|
||
|
||
self.profile_list = QListWidget()
|
||
self.profile_list.itemDoubleClicked.connect(lambda item: self.load_profile(item.text()))
|
||
layout.addWidget(self.profile_list, 1)
|
||
|
||
btn_layout = QHBoxLayout()
|
||
load_btn = QPushButton("📂 Load")
|
||
load_btn.clicked.connect(lambda: self.load_profile(self.profile_list.currentItem().text() if self.profile_list.currentItem() else ""))
|
||
btn_layout.addWidget(load_btn)
|
||
|
||
delete_btn = QPushButton("🗑️ Delete")
|
||
delete_btn.clicked.connect(self.delete_profile)
|
||
btn_layout.addWidget(delete_btn)
|
||
btn_layout.addStretch()
|
||
layout.addLayout(btn_layout)
|
||
|
||
return widget
|
||
|
||
# --- Nexus tab ---
|
||
|
||
def _create_nexus_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
# API key
|
||
key_group = QGroupBox("Nexus Mods API Key")
|
||
key_layout = QVBoxLayout(key_group)
|
||
key_layout.addWidget(QLabel("Get your API key from: https://www.nexusmods.com/users/myaccount?tab=api%20keys"))
|
||
key_input_layout = QHBoxLayout()
|
||
self.nexus_key_input = QLineEdit()
|
||
self.nexus_key_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||
self.nexus_key_input.setPlaceholderText("Paste your Nexus API key here...")
|
||
self.nexus_key_input.setText(self.config.get("nexus_api_key", ""))
|
||
key_input_layout.addWidget(self.nexus_key_input, 1)
|
||
save_key_btn = QPushButton("Save")
|
||
save_key_btn.clicked.connect(self.save_nexus_key)
|
||
key_input_layout.addWidget(save_key_btn)
|
||
key_layout.addLayout(key_input_layout)
|
||
layout.addWidget(key_group)
|
||
|
||
# NXM download
|
||
dl_group = QGroupBox("Manual NXM Download")
|
||
dl_layout = QVBoxLayout(dl_group)
|
||
dl_layout.addWidget(QLabel("Paste an nxm:// URL from Nexus Mods:"))
|
||
url_layout = QHBoxLayout()
|
||
self.nxm_url_input = QLineEdit()
|
||
self.nxm_url_input.setPlaceholderText("nxm://cyberpunk2077/mods/123/files/456?key=...")
|
||
url_layout.addWidget(self.nxm_url_input, 1)
|
||
dl_btn = QPushButton("⬇️ Download & Install")
|
||
dl_btn.clicked.connect(self.download_nxm)
|
||
url_layout.addWidget(dl_btn)
|
||
dl_layout.addLayout(url_layout)
|
||
|
||
self.nexus_progress = QProgressBar()
|
||
self.nexus_progress.setVisible(False)
|
||
dl_layout.addWidget(self.nexus_progress)
|
||
|
||
self.nexus_status = QLabel("")
|
||
dl_layout.addWidget(self.nexus_status)
|
||
layout.addWidget(dl_group)
|
||
|
||
# Browser integration
|
||
info_group = QGroupBox("Browser Integration")
|
||
info_layout = QVBoxLayout(info_group)
|
||
info_layout.addWidget(QLabel("To auto-catch nxm:// links from your browser:"))
|
||
register_btn = QPushButton("🔗 Register nxm:// Protocol Handler")
|
||
register_btn.clicked.connect(self.register_nxm_protocol)
|
||
info_layout.addWidget(register_btn)
|
||
info_layout.addWidget(QLabel("This lets clicking 'Download' on Nexus Mods\nautomatically download the mod here."))
|
||
layout.addWidget(info_group)
|
||
layout.addStretch()
|
||
|
||
return widget
|
||
|
||
# --- Settings tab ---
|
||
|
||
def _create_settings_tab(self):
|
||
widget = QWidget()
|
||
layout = QVBoxLayout(widget)
|
||
|
||
path_group = QGroupBox("Game Path")
|
||
path_layout = QVBoxLayout(path_group)
|
||
path_layout.addWidget(QLabel("Cyberpunk 2077 installation directory:"))
|
||
path_input_layout = QHBoxLayout()
|
||
self.game_path_input = QLineEdit()
|
||
self.game_path_input.setText(self.config.get("game_path", DEFAULT_GAME_PATH))
|
||
path_input_layout.addWidget(self.game_path_input, 1)
|
||
path_browse_btn = QPushButton("📂")
|
||
path_browse_btn.clicked.connect(self.browse_game_path_settings)
|
||
path_input_layout.addWidget(path_browse_btn)
|
||
path_layout.addLayout(path_input_layout)
|
||
|
||
save_path_btn = QPushButton("Save")
|
||
save_path_btn.clicked.connect(self.save_game_path)
|
||
path_layout.addWidget(save_path_btn)
|
||
layout.addWidget(path_group)
|
||
|
||
order_group = QGroupBox("Load Order")
|
||
order_layout = QVBoxLayout(order_group)
|
||
self.auto_prefix_check = QCheckBox("Auto-assign numeric prefixes (000_, 010_, 020_)")
|
||
self.auto_prefix_check.setChecked(self.config.get("auto_prefix", True))
|
||
order_layout.addWidget(self.auto_prefix_check)
|
||
order_layout.addWidget(QLabel("Higher numbers = loaded later = overrides earlier mods"))
|
||
layout.addWidget(order_group)
|
||
|
||
# Tools check
|
||
tools_group = QGroupBox("Extraction Tools")
|
||
tools_layout = QVBoxLayout(tools_group)
|
||
tools_layout.addWidget(QLabel("Required for auto-extracting .7z / .zip / .rar downloads:"))
|
||
|
||
for tool, pkg in [("7z", "p7zip"), ("unrar", "unrar"), ("zipinfo", "zip")]:
|
||
found = shutil.which(tool) is not None
|
||
label = QLabel(f" {'✅' if found else '❌'} {tool} ({pkg})")
|
||
label.setStyleSheet(f"color: {'#00ff9f' if found else '#ff0080'};")
|
||
tools_layout.addWidget(label)
|
||
|
||
layout.addWidget(tools_group)
|
||
layout.addStretch()
|
||
return widget
|
||
|
||
# ===========================================================================
|
||
# ACTIONS
|
||
# ===========================================================================
|
||
|
||
def get_mod_dir(self):
|
||
return Path(self.config["game_path"]) / self.config["mod_dir"]
|
||
|
||
def refresh_all(self):
|
||
"""Refresh mods, script mods, and profiles."""
|
||
self.refresh_mods()
|
||
self.refresh_script_mods()
|
||
|
||
def refresh_mods(self):
|
||
"""Scan mod directory and update the list."""
|
||
mod_dir = self.get_mod_dir()
|
||
if not mod_dir.exists():
|
||
self.status.showMessage(f"Mod directory not found: {mod_dir}")
|
||
return
|
||
|
||
self.mods = scan_mods(mod_dir)
|
||
self._update_mod_list()
|
||
self.status.showMessage(f"Found {len(self.mods)} mods in {mod_dir}")
|
||
|
||
def refresh_script_mods(self):
|
||
"""Scan for script-based mods."""
|
||
self.script_mods = scan_script_mods(self.config["game_path"])
|
||
self._update_script_list()
|
||
|
||
def _update_mod_list(self):
|
||
self.mod_list.blockSignals(True)
|
||
self.mod_list.clear()
|
||
|
||
for mod in self.mods:
|
||
item = QTreeWidgetItem()
|
||
item.setCheckState(0, Qt.CheckState.Checked if mod.enabled else Qt.CheckState.Unchecked)
|
||
item.setText(1, mod.name)
|
||
item.setText(2, f"{mod.size_mb:.1f} MB")
|
||
item.setText(3, mod.sort_key)
|
||
item.setText(4, "✅ Enabled" if mod.enabled else "❌ Disabled")
|
||
|
||
# Parse archive for file count (lazy — only if not already parsed)
|
||
if not mod.info:
|
||
mod.info = read_archive_info(mod.path)
|
||
file_count = mod.info.get("file_count", 0)
|
||
item.setText(5, str(file_count) if file_count else "?")
|
||
|
||
if not mod.enabled:
|
||
item.setForeground(4, QColor("#ff0080"))
|
||
else:
|
||
item.setForeground(4, QColor("#00ff9f"))
|
||
|
||
item.setData(0, Qt.ItemDataRole.UserRole, mod)
|
||
self.mod_list.addTopLevelItem(item)
|
||
|
||
self.mod_list.blockSignals(False)
|
||
|
||
def _update_script_list(self):
|
||
"""Update the script mods tree."""
|
||
self.script_tree.clear()
|
||
|
||
icons = {
|
||
"redscript": "📜",
|
||
"tweakxl": "⚙️",
|
||
"cet": "🔧",
|
||
"red4ext": "📦",
|
||
"archivexl": "🗂️",
|
||
}
|
||
|
||
total_mods = 0
|
||
total_files = 0
|
||
|
||
for category, files in self.script_mods.items():
|
||
if not files:
|
||
continue
|
||
|
||
# Group by mod name (top-level folder or filename)
|
||
mod_groups = {}
|
||
for f in files:
|
||
# Extract mod name from path
|
||
path_parts = f.split("/")
|
||
if category == "cet" and len(path_parts) >= 7:
|
||
# bin/x64/plugins/cyber_engine_tweaks/mods/<modname>/...
|
||
mod_name = path_parts[6]
|
||
elif category == "red4ext" and len(path_parts) >= 3:
|
||
# red4ext/plugins/<modname>/...
|
||
mod_name = path_parts[2]
|
||
elif category == "redscript" and len(path_parts) >= 3:
|
||
# r6/scripts/<modname>/...
|
||
mod_name = path_parts[2] if path_parts[2] != "r6" else path_parts[-1]
|
||
elif category == "tweakxl" and len(path_parts) >= 3:
|
||
# r6/tweaks/<modname>/...
|
||
mod_name = path_parts[2] if path_parts[2] != "r6" else path_parts[-1]
|
||
else:
|
||
mod_name = path_parts[-1]
|
||
|
||
if mod_name not in mod_groups:
|
||
mod_groups[mod_name] = []
|
||
mod_groups[mod_name].append(f)
|
||
|
||
parent = QTreeWidgetItem()
|
||
parent.setText(0, f"{icons.get(category, '📋')} {category.upper()}")
|
||
parent.setText(1, f"{len(mod_groups)} mods")
|
||
parent.setText(3, f"{len(files)} files")
|
||
parent.setText(4, "✅ Active")
|
||
parent.setForeground(0, QColor("#00ff9f"))
|
||
parent.setForeground(4, QColor("#00ff9f"))
|
||
parent.setExpanded(True)
|
||
|
||
for mod_name, mod_files in sorted(mod_groups.items()):
|
||
child = QTreeWidgetItem()
|
||
child.setText(1, mod_name)
|
||
child.setText(3, str(len(mod_files)))
|
||
|
||
# Check if disabled (.disabled suffix on folder or file)
|
||
is_disabled = any(".disabled" in f for f in mod_files)
|
||
child.setText(4, "❌ Disabled" if is_disabled else "✅ Active")
|
||
if is_disabled:
|
||
child.setForeground(4, QColor("#ff0080"))
|
||
else:
|
||
child.setForeground(4, QColor("#00ff9f"))
|
||
|
||
child.setData(0, Qt.ItemDataRole.UserRole, {
|
||
"category": category,
|
||
"mod_name": mod_name,
|
||
"files": mod_files,
|
||
"disabled": is_disabled
|
||
})
|
||
|
||
# Show first file as path hint
|
||
child.setText(2, mod_files[0] if mod_files else "")
|
||
|
||
parent.addChild(child)
|
||
total_mods += 1
|
||
total_files += len(mod_files)
|
||
|
||
self.script_tree.addTopLevelItem(parent)
|
||
parent.setExpanded(True)
|
||
|
||
self.script_info_label.setText(f"Total: {total_mods} mods, {total_files} files")
|
||
|
||
def _toggle_script_mod(self):
|
||
"""Toggle selected script mod on/off."""
|
||
item = self.script_tree.currentItem()
|
||
if not item:
|
||
return
|
||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||
if not data:
|
||
return
|
||
|
||
game_path = Path(self.config["game_path"])
|
||
mod_name = data["mod_name"]
|
||
category = data["category"]
|
||
files = data["files"]
|
||
disabled = data["disabled"]
|
||
|
||
for f in files:
|
||
src = game_path / f
|
||
if not src.exists():
|
||
continue
|
||
|
||
if disabled:
|
||
# Enable: remove .disabled suffix
|
||
dst = Path(str(src).replace(".disabled", ""))
|
||
if src != dst:
|
||
src.rename(dst)
|
||
else:
|
||
# Disable: add .disabled suffix
|
||
dst = Path(str(src) + ".disabled")
|
||
src.rename(dst)
|
||
|
||
data["disabled"] = not disabled
|
||
item.setText(4, "❌ Disabled" if data["disabled"] else "✅ Active")
|
||
if data["disabled"]:
|
||
item.setForeground(4, QColor("#ff0080"))
|
||
else:
|
||
item.setForeground(4, QColor("#00ff9f"))
|
||
|
||
self.status.showMessage(f"{'Disabled' if data['disabled'] else 'Enabled'}: {mod_name}")
|
||
|
||
def _script_context_menu(self, pos):
|
||
"""Right-click context menu for script mods."""
|
||
item = self.script_tree.itemAt(pos)
|
||
if not item:
|
||
return
|
||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||
if not data:
|
||
return
|
||
|
||
mod_name = data["mod_name"]
|
||
disabled = data["disabled"]
|
||
|
||
menu = QMenu(self)
|
||
|
||
toggle_action = QAction("Enable" if disabled else "Disable", self)
|
||
toggle_action.triggered.connect(self._toggle_script_mod)
|
||
menu.addAction(toggle_action)
|
||
|
||
menu.addSeparator()
|
||
|
||
show_files = QAction("📋 Show Files", self)
|
||
files_text = "\n".join(data["files"][:20])
|
||
if len(data["files"]) > 20:
|
||
files_text += f"\n... and {len(data['files']) - 20} more"
|
||
show_files.triggered.connect(lambda: QMessageBox.information(self, f"{mod_name} — Files", files_text))
|
||
menu.addAction(show_files)
|
||
|
||
menu.addSeparator()
|
||
|
||
show_path = QAction("📂 Open Folder", self)
|
||
game_path = Path(self.config["game_path"])
|
||
first_file = game_path / data["files"][0]
|
||
show_path.triggered.connect(lambda: subprocess.Popen(["xdg-open", str(first_file.parent)]))
|
||
menu.addAction(show_path)
|
||
|
||
menu.exec(self.script_tree.viewport().mapToGlobal(pos))
|
||
|
||
def _install_from_folder(self):
|
||
"""Install mods from a folder."""
|
||
folder = QFileDialog.getExistingDirectory(self, "Select mod folder", str(Path.home() / "Dokumente"))
|
||
if not folder:
|
||
return
|
||
|
||
game_path = self.config["game_path"]
|
||
mod_folder = Path(folder)
|
||
|
||
# Check if it's a parent folder with multiple mods
|
||
subdirs = [d for d in mod_folder.iterdir() if d.is_dir() and not d.name.startswith(".")]
|
||
|
||
reply = QMessageBox.question(self, "Install Mods",
|
||
f"Install {len(subdirs)} mods from:\n{mod_folder}\n\nTo: {game_path}?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
total_installed = {}
|
||
total_errors = []
|
||
|
||
for subdir in subdirs:
|
||
installed, errors = install_mod_from_folder(subdir, game_path)
|
||
for k, v in installed.items():
|
||
total_installed[k] = total_installed.get(k, 0) + v
|
||
total_errors.extend(errors)
|
||
|
||
summary = "\n".join(f" {k}: {v}" for k, v in total_installed.items() if v > 0)
|
||
summary += f"\n Total: {sum(total_installed.values())} files"
|
||
|
||
if total_errors:
|
||
summary += f"\n\nErrors ({len(total_errors)}):"
|
||
for e in total_errors[:5]:
|
||
summary += f"\n {e}"
|
||
if len(total_errors) > 5:
|
||
summary += f"\n ... and {len(total_errors) - 5} more"
|
||
|
||
QMessageBox.information(self, "Installation Complete", summary)
|
||
self.status.showMessage(f"Installed {sum(total_installed.values())} files")
|
||
self.refresh_script_mods()
|
||
|
||
def _compile_redscript(self):
|
||
"""Compile redscript via Wine."""
|
||
game_path = self.config["game_path"]
|
||
|
||
scc = find_scc_exe()
|
||
if not scc:
|
||
QMessageBox.warning(self, "redscript not found",
|
||
"redscript compiler (scc.exe) not found.\n"
|
||
"Install the 'redscript' mod first.\n"
|
||
"Check: Fluorine mods or game directory.")
|
||
return
|
||
|
||
self.status.showMessage("Compiling redscript... please wait")
|
||
QApplication.processEvents()
|
||
|
||
success, output = compile_redscript(game_path)
|
||
|
||
if success:
|
||
self.status.showMessage("✅ redscript compiled successfully!")
|
||
QMessageBox.information(self, "redscript", "✅ Compilation successful!\n\n" + output[-500:])
|
||
else:
|
||
self.status.showMessage("❌ redscript compilation failed")
|
||
QMessageBox.warning(self, "redscript", "❌ Compilation failed:\n\n" + output[-1000:])
|
||
|
||
def _on_mod_checkbox_changed(self, item, column):
|
||
if column != 0:
|
||
return
|
||
mod = item.data(0, Qt.ItemDataRole.UserRole)
|
||
if not mod:
|
||
return
|
||
|
||
new_state = item.checkState(0) == Qt.CheckState.Checked
|
||
if new_state != mod.enabled:
|
||
toggle_mod(mod)
|
||
item.setText(4, "✅ Enabled" if mod.enabled else "❌ Disabled")
|
||
if mod.enabled:
|
||
item.setForeground(4, QColor("#00ff9f"))
|
||
else:
|
||
item.setForeground(4, QColor("#ff0080"))
|
||
self.status.showMessage(f"{'Enabled' if mod.enabled else 'Disabled'}: {mod.name}")
|
||
|
||
def _mod_context_menu(self, pos):
|
||
item = self.mod_list.itemAt(pos)
|
||
if not item:
|
||
return
|
||
mod = item.data(0, Qt.ItemDataRole.UserRole)
|
||
|
||
menu = QMenu(self)
|
||
|
||
toggle_action = QAction("Disable" if mod.enabled else "Enable", self)
|
||
toggle_action.triggered.connect(lambda: self._on_mod_checkbox_changed(item, 0))
|
||
menu.addAction(toggle_action)
|
||
|
||
menu.addSeparator()
|
||
|
||
# Show internal files
|
||
show_files = QAction("📋 Show Internal Files", self)
|
||
show_files.triggered.connect(lambda: self._show_internal_files(mod))
|
||
menu.addAction(show_files)
|
||
|
||
menu.addSeparator()
|
||
|
||
move_up = QAction("⬆️ Move Up (earlier)", self)
|
||
move_up.triggered.connect(lambda: self._move_mod(item, up=True))
|
||
menu.addAction(move_up)
|
||
|
||
move_down = QAction("⬇️ Move Down (later)", self)
|
||
move_down.triggered.connect(lambda: self._move_mod(item, up=False))
|
||
menu.addAction(move_down)
|
||
|
||
menu.addSeparator()
|
||
|
||
delete_action = QAction("🗑️ Delete Mod", self)
|
||
delete_action.triggered.connect(lambda: self._delete_mod(item))
|
||
menu.addAction(delete_action)
|
||
|
||
menu.exec(self.mod_list.mapToGlobal(pos))
|
||
|
||
def _show_internal_files(self, mod):
|
||
"""Show internal file list of a mod in the details panel."""
|
||
if not mod.info:
|
||
mod.info = read_archive_info(mod.path)
|
||
|
||
info = mod.info
|
||
text = f"<b>{mod.name}</b><br>"
|
||
text += f"Size: {mod.size_mb:.1f} MB<br>"
|
||
text += f"Version: {info.get('version', '?')}<br>"
|
||
text += f"Files: {info.get('file_count', '?')}<br><br>"
|
||
|
||
files = info.get("files", [])
|
||
if files:
|
||
text += "<b>Internal files:</b><br>"
|
||
for f in files[:50]:
|
||
text += f" {f}<br>"
|
||
if len(files) > 50:
|
||
text += f" ... and {len(files) - 50} more<br>"
|
||
else:
|
||
text += "<i>No file list could be parsed</i><br>"
|
||
|
||
self.details_label.setHtml(text)
|
||
|
||
def _move_mod(self, item, up=True):
|
||
idx = self.mod_list.indexOfTopLevelItem(item)
|
||
if up and idx > 0:
|
||
self.mod_list.takeTopLevelItem(idx)
|
||
self.mod_list.insertTopLevelItem(idx - 1, item)
|
||
self.mod_list.setCurrentItem(item)
|
||
elif not up and idx < self.mod_list.topLevelItemCount() - 1:
|
||
self.mod_list.takeTopLevelItem(idx)
|
||
self.mod_list.insertTopLevelItem(idx + 1, item)
|
||
self.mod_list.setCurrentItem(item)
|
||
self._apply_order_from_list()
|
||
|
||
def _apply_order_from_list(self):
|
||
order = []
|
||
for i in range(self.mod_list.topLevelItemCount()):
|
||
item = self.mod_list.topLevelItem(i)
|
||
mod = item.data(0, Qt.ItemDataRole.UserRole)
|
||
if mod:
|
||
order.append(mod.name)
|
||
set_load_order(self.mods, order)
|
||
self.refresh_mods()
|
||
|
||
def _delete_mod(self, item):
|
||
mod = item.data(0, Qt.ItemDataRole.UserRole)
|
||
reply = QMessageBox.question(self, "Delete Mod",
|
||
f"Delete '{mod.name}' ({mod.size_mb:.1f} MB)?\nThis cannot be undone!",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
mod.path.unlink()
|
||
self.refresh_mods()
|
||
self.status.showMessage(f"Deleted: {mod.name}")
|
||
|
||
def install_mod(self, filepath):
|
||
"""Install a mod file — auto-detect and handle .archive, .zip, .7z, .yaml, .reds"""
|
||
game_path = self.config["game_path"]
|
||
|
||
installed = install_mod_file(filepath, game_path,
|
||
status_callback=lambda msg: self.status.showMessage(msg))
|
||
|
||
if installed:
|
||
msg = f"Installed {len(installed)} files:\n" + "\n".join(installed[:20])
|
||
if len(installed) > 20:
|
||
msg += f"\n... and {len(installed) - 20} more"
|
||
self.status.showMessage(f"Installed {len(installed)} files from {Path(filepath).name}")
|
||
QMessageBox.information(self, "Mod Installed", msg)
|
||
self.refresh_all()
|
||
else:
|
||
QMessageBox.warning(self, "Install Failed",
|
||
f"Could not install {filepath}.\nUnsupported file type or extraction failed.")
|
||
|
||
def add_mod(self):
|
||
"""Add mod files from file dialog — supports .archive, .zip, .7z, .rar, .yaml, .reds"""
|
||
files, _ = QFileDialog.getOpenFileNames(self, "Select mod files",
|
||
"", "Mod files (*.archive *.zip *.7z *.rar *.yaml *.yml *.reds *.xl);;All files (*.*)")
|
||
for f in files:
|
||
self.install_mod(f)
|
||
|
||
def add_script_mod(self):
|
||
"""Add script mod files — .yaml, .reds, .zip, .7z"""
|
||
files, _ = QFileDialog.getOpenFileNames(self, "Select script mod files",
|
||
"", "Script mods (*.yaml *.yml *.reds *.zip *.7z);;All files (*.*)")
|
||
for f in files:
|
||
self.install_mod(f)
|
||
|
||
def enable_all_mods(self):
|
||
for mod in self.mods:
|
||
if not mod.enabled:
|
||
toggle_mod(mod)
|
||
self.refresh_mods()
|
||
self.status.showMessage("All mods enabled")
|
||
|
||
def disable_all_mods(self):
|
||
for mod in self.mods:
|
||
if mod.enabled:
|
||
toggle_mod(mod)
|
||
self.refresh_mods()
|
||
self.status.showMessage("All mods disabled")
|
||
|
||
def auto_sort_mods(self):
|
||
self._apply_order_from_list()
|
||
self.status.showMessage("Load order applied")
|
||
|
||
def browse_game_path(self):
|
||
path = QFileDialog.getExistingDirectory(self, "Select Cyberpunk 2077 directory",
|
||
self.config.get("game_path", DEFAULT_GAME_PATH))
|
||
if path:
|
||
self.config["game_path"] = path
|
||
save_config(self.config)
|
||
self.game_path_label.setText(path)
|
||
self.refresh_all()
|
||
|
||
def browse_game_path_settings(self):
|
||
path = QFileDialog.getExistingDirectory(self, "Select Cyberpunk 2077 directory",
|
||
self.game_path_input.text())
|
||
if path:
|
||
self.game_path_input.setText(path)
|
||
|
||
def save_game_path(self):
|
||
self.config["game_path"] = self.game_path_input.text()
|
||
self.config["auto_prefix"] = self.auto_prefix_check.isChecked()
|
||
save_config(self.config)
|
||
self.game_path_label.setText(self.config["game_path"])
|
||
self.status.showMessage("Settings saved")
|
||
|
||
# --- Conflict scanning ---
|
||
|
||
def scan_conflicts_quick(self):
|
||
"""Quick conflict scan based on mod names only."""
|
||
self.conflict_tree.clear()
|
||
name_map = {}
|
||
for mod in self.mods:
|
||
if not mod.enabled:
|
||
continue
|
||
base = mod.name.lower()
|
||
if base not in name_map:
|
||
name_map[base] = []
|
||
name_map[base].append(mod)
|
||
|
||
for base, mod_list in name_map.items():
|
||
if len(mod_list) > 1:
|
||
for i in range(len(mod_list)):
|
||
for j in range(i + 1, len(mod_list)):
|
||
item = QTreeWidgetItem()
|
||
item.setText(0, base)
|
||
item.setText(1, mod_list[i].filename)
|
||
item.setText(2, mod_list[j].filename)
|
||
item.setText(4, "2")
|
||
item.setForeground(3, QColor("#ff9500"))
|
||
self.conflict_tree.addTopLevelItem(item)
|
||
|
||
if self.conflict_tree.topLevelItemCount() == 0:
|
||
item = QTreeWidgetItem()
|
||
item.setText(0, "No conflicts detected ✅")
|
||
self.conflict_tree.addTopLevelItem(item)
|
||
self.status.showMessage(f"Quick scan: {self.conflict_tree.topLevelItemCount()} items")
|
||
|
||
def scan_conflicts_deep(self):
|
||
"""Deep conflict scan — parse all .archive files and compare internal paths."""
|
||
self.conflict_tree.clear()
|
||
self.conflict_progress.setVisible(True)
|
||
self.conflict_progress.setRange(0, len(self.mods))
|
||
|
||
def progress(current, total, msg):
|
||
self.conflict_progress.setValue(current)
|
||
self.status.showMessage(msg)
|
||
QApplication.processEvents()
|
||
|
||
conflicts = scan_archive_conflicts(self.mods, progress_callback=progress)
|
||
|
||
self.conflict_progress.setVisible(False)
|
||
|
||
if not conflicts:
|
||
item = QTreeWidgetItem()
|
||
item.setText(0, "No conflicts detected ✅")
|
||
self.conflict_tree.addTopLevelItem(item)
|
||
else:
|
||
for c in sorted(conflicts, key=lambda x: -x["count"]):
|
||
item = QTreeWidgetItem()
|
||
item.setText(0, c["file"])
|
||
for i, mod_name in enumerate(c["mods"][:3]):
|
||
item.setText(i + 1, mod_name)
|
||
item.setText(4, str(c["count"]))
|
||
if c["count"] > 2:
|
||
item.setForeground(0, QColor("#ff0080"))
|
||
else:
|
||
item.setForeground(0, QColor("#ff9500"))
|
||
self.conflict_tree.addTopLevelItem(item)
|
||
|
||
self.status.showMessage(f"Deep scan: {len(conflicts)} conflicting files found")
|
||
|
||
# --- Profiles ---
|
||
|
||
def _refresh_profiles(self):
|
||
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||
profiles = [f.stem for f in PROFILES_DIR.glob("*.json")]
|
||
if not profiles:
|
||
profiles = ["Default"]
|
||
self._save_profile_data("Default")
|
||
|
||
self.profile_combo.blockSignals(True)
|
||
self.profile_combo.clear()
|
||
self.profile_combo.addItems(profiles)
|
||
if self.current_profile in profiles:
|
||
self.profile_combo.setCurrentText(self.current_profile)
|
||
self.profile_combo.blockSignals(False)
|
||
|
||
if hasattr(self, "profile_list"):
|
||
self.profile_list.clear()
|
||
self.profile_list.addItems(profiles)
|
||
|
||
def _save_profile_data(self, name):
|
||
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||
data = {
|
||
"mods": [{"name": m.name, "enabled": m.enabled, "order": m.sort_key} for m in self.mods],
|
||
"saved_at": datetime.now().isoformat(),
|
||
}
|
||
with open(PROFILES_DIR / f"{name}.json", "w") as f:
|
||
json.dump(data, f, indent=2)
|
||
|
||
def save_profile(self):
|
||
name = self.profile_name_input.text().strip()
|
||
if not name:
|
||
QMessageBox.warning(self, "No name", "Please enter a profile name.")
|
||
return
|
||
self._save_profile_data(name)
|
||
self._refresh_profiles()
|
||
self.status.showMessage(f"Profile saved: {name}")
|
||
|
||
def load_profile(self, name):
|
||
if not name:
|
||
return
|
||
path = PROFILES_DIR / f"{name}.json"
|
||
if not path.exists():
|
||
return
|
||
|
||
with open(path, "r") as f:
|
||
data = json.load(f)
|
||
|
||
desired = {m["name"]: m["enabled"] for m in data.get("mods", [])}
|
||
desired_order = [m["name"] for m in data.get("mods", [])]
|
||
|
||
for mod in self.mods:
|
||
should_enable = desired.get(mod.name, None)
|
||
if should_enable is True and not mod.enabled:
|
||
toggle_mod(mod)
|
||
elif should_enable is False and mod.enabled:
|
||
toggle_mod(mod)
|
||
|
||
if desired_order:
|
||
set_load_order(self.mods, desired_order)
|
||
|
||
self.current_profile = name
|
||
self.config["last_profile"] = name
|
||
save_config(self.config)
|
||
self.refresh_mods()
|
||
self.status.showMessage(f"Profile loaded: {name}")
|
||
|
||
def delete_profile(self):
|
||
item = self.profile_list.currentItem()
|
||
if not item:
|
||
return
|
||
name = item.text()
|
||
if name == "Default":
|
||
QMessageBox.warning(self, "Cannot delete", "Cannot delete Default profile.")
|
||
return
|
||
reply = QMessageBox.question(self, "Delete Profile",
|
||
f"Delete profile '{name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
(PROFILES_DIR / f"{name}.json").unlink()
|
||
self._refresh_profiles()
|
||
self.status.showMessage(f"Profile deleted: {name}")
|
||
|
||
# --- Nexus ---
|
||
|
||
def save_nexus_key(self):
|
||
key = self.nexus_key_input.text().strip()
|
||
self.config["nexus_api_key"] = key
|
||
save_config(self.config)
|
||
NEXUS_API_KEY_FILE.write_text(key)
|
||
NEXUS_API_KEY_FILE.chmod(0o600)
|
||
self.status.showMessage("Nexus API key saved")
|
||
|
||
def download_nxm(self):
|
||
url = self.nxm_url_input.text().strip()
|
||
if not url:
|
||
return
|
||
|
||
api_key = self.config.get("nexus_api_key", "")
|
||
if not api_key:
|
||
QMessageBox.warning(self, "No API key", "Please set your Nexus Mods API key first.")
|
||
self.tabs.setCurrentIndex(4)
|
||
return
|
||
|
||
self.nexus_progress.setVisible(True)
|
||
self.nexus_progress.setRange(0, 0)
|
||
self.nexus_status.setText("Downloading...")
|
||
|
||
import threading
|
||
def do_download():
|
||
download_dir = Path.home() / "Downloads" / "nexus-mods"
|
||
download_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
filepath, error = download_nxm_mod(url, api_key, download_dir)
|
||
|
||
def on_done():
|
||
self.nexus_progress.setVisible(False)
|
||
if error:
|
||
self.nexus_status.setText(f"❌ {error}")
|
||
elif filepath:
|
||
self.nexus_status.setText(f"✅ Downloaded: {filepath.name}")
|
||
# Auto-install
|
||
installed = install_mod_file(filepath, self.config["game_path"],
|
||
status_callback=lambda msg: self.status.showMessage(msg))
|
||
if installed:
|
||
msg = f"Downloaded and installed {len(installed)} files:\n" + "\n".join(installed[:20])
|
||
if len(installed) > 20:
|
||
msg += f"\n... and {len(installed) - 20} more"
|
||
QMessageBox.information(self, "Mod Installed", msg)
|
||
self.refresh_all()
|
||
else:
|
||
QMessageBox.warning(self, "Install Failed",
|
||
f"Downloaded {filepath.name} but could not auto-install.\n"
|
||
f"File saved at: {filepath}")
|
||
|
||
QTimer.singleShot(0, on_done)
|
||
|
||
threading.Thread(target=do_download, daemon=True).start()
|
||
|
||
def register_nxm_protocol(self):
|
||
desktop_dir = Path.home() / ".local" / "share" / "applications"
|
||
desktop_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
script_path = Path(__file__).resolve()
|
||
desktop_file = desktop_dir / "neon-mod-manager-nxm.desktop"
|
||
|
||
desktop_content = f"""[Desktop Entry]
|
||
Type=Application
|
||
Name=Neon Mod Manager (NXM Handler)
|
||
Exec=python3 {script_path} --nxm %u
|
||
Terminal=false
|
||
MimeType=x-scheme-handler/nxm;
|
||
NoDisplay=true
|
||
"""
|
||
desktop_file.write_text(desktop_content)
|
||
subprocess.run(["update-desktop-database", str(desktop_dir)], capture_output=True)
|
||
|
||
self.status.showMessage("nxm:// protocol registered!")
|
||
QMessageBox.information(self, "Protocol Registered",
|
||
"nxm:// protocol handler registered!\n\n"
|
||
"When you click 'Download' on Nexus Mods,\n"
|
||
"the mod will be sent to Neon Mod Manager.")
|
||
|
||
|
||
def main():
|
||
app = QApplication(sys.argv)
|
||
|
||
# Check for --nxm argument (protocol handler invocation)
|
||
if len(sys.argv) > 2 and sys.argv[1] == "--nxm":
|
||
nxm_url = sys.argv[2]
|
||
config = load_config()
|
||
api_key = config.get("nexus_api_key", "")
|
||
if not api_key and NEXUS_API_KEY_FILE.exists():
|
||
api_key = NEXUS_API_KEY_FILE.read_text().strip()
|
||
|
||
if not api_key:
|
||
print("No API key set. Run Neon Mod Manager GUI to set your key.")
|
||
sys.exit(1)
|
||
|
||
download_dir = Path.home() / "Downloads" / "nexus-mods"
|
||
download_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
filepath, error = download_nxm_mod(nxm_url, api_key, download_dir)
|
||
if error:
|
||
print(f"Error: {error}")
|
||
sys.exit(1)
|
||
|
||
print(f"Downloaded: {filepath}")
|
||
|
||
# Auto-install
|
||
installed = install_mod_file(filepath, config["game_path"])
|
||
if installed:
|
||
print(f"Installed {len(installed)} files to {config['game_path']}")
|
||
else:
|
||
print(f"File saved at: {filepath} (manual install needed)")
|
||
sys.exit(0)
|
||
|
||
# Normal GUI mode
|
||
window = ModManagerWindow()
|
||
window.show()
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |