diff --git a/.env b/.env new file mode 100644 index 0000000..e787f9f --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +# Default Collection URLs +CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585 +CONTENT_COLLECTION_URL=https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712 +COSMETICS_COLLECTION_URL=https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646 + +# ModsConfig.xml Path Template +MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..381495d --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Default Collection URLs +CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585 +CONTENT_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712 +COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646 + +# ModsConfig.xml Path Template +MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..e1ee600 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,70 @@ +name: Build and Upload Release (Windows EXE) + +on: + release: + types: [published] + workflow_dispatch: {} + +jobs: + build-windows-exe: + name: Build Windows EXE + runs-on: windows + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + shell: powershell + run: | + python -m pip install --upgrade pip + if (Test-Path requirements.txt) { pip install -r requirements.txt } + pip install pyinstaller + + - name: Build EXE with PyInstaller + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + # Stamp version into sealoader_version.py from release tag + if ($env:GITHUB_EVENT_NAME -eq 'release') { + $tag = '${{ github.event.release.tag_name }}' + } else { + $tag = (git describe --tags --always) 2>$null + if (-not $tag) { $tag = "0.0.0-dev" } + } + ("__version__ = '" + $tag + "'") | Out-File -FilePath sealoader_version.py -Encoding UTF8 -Force + # Bundle PNG resources referenced at runtime + pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader ` + --add-data "SeaLoader.png;." ` + --add-data "hrsys.png;." ` + --icon SeaLoader.ico + + - name: Prepare artifact + shell: powershell + run: | + New-Item -ItemType Directory -Force -Path dist_upload | Out-Null + Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe + if (Test-Path README.md) { Copy-Item README.md dist_upload\ } + if (Test-Path LICENSE) { Copy-Item LICENSE dist_upload\ } + Compress-Archive -Path dist_upload\* -DestinationPath SeaLoader_Windows_x64.zip -Force + + - name: Upload asset to Release + if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + shell: powershell + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RELEASE_ID: ${{ github.event.release.id }} + SERVER_URL: ${{ github.server_url }} + run: | + $ErrorActionPreference = 'Stop' + $uploadUrl = "$env:SERVER_URL/api/v1/repos/$env:REPO/releases/$env:RELEASE_ID/assets?name=SeaLoader_Windows_x64.zip" + Write-Host "Uploading asset to $uploadUrl" + Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{ Authorization = "token $env:TOKEN" } -ContentType "application/zip" -InFile "SeaLoader_Windows_x64.zip" + + # CI artifact upload removed for GHES compatibility + diff --git a/About.xml b/About.xml deleted file mode 100644 index 292bf0c..0000000 --- a/About.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - RT Fuse - Ratys - ratys.rtfuse - https://ludeon.com/forums/index.php?topic=11272 - -
  • 1.0
  • -
  • 1.1
  • -
  • 1.2
  • -
  • 1.3
  • -
  • 1.4
  • -
  • 1.5
  • -
  • 1.6
  • -
    - -
  • - brrainz.harmony - Harmony - steam://url/CommunityFilePage/2009463077 - https://github.com/pardeike/HarmonyRimWorld/releases/latest -
  • -
    - Researchable (RT Mods research tab) electric fuses to mitigate short circuits. When placed anywhere on a power network, each fuse will safely discharge up to three of network's batteries, mitigating or preventing the explosion. - -Does not require a new colony to add or remove (might throw a one-time error). - -Refer to forum thread for additional information. -
    \ No newline at end of file diff --git a/README.md b/README.md index a6a1dc4..a57e853 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # Steam Workshop Collection Manager -A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod collections. +A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod collections with configurable settings. ## Features - Dark themed Windows-style GUI -- Three input fields for different mod collection types (Core, Content, Cosmetics) -- Automatic extraction of Workshop IDs from Steam collection URLs -- Real-time output display with scrollable text area -- Pre-populated with example RimWorld mod collections +- Configurable collection URLs via .env file +- Automatic workshop path derivation from RimWorld game path +- ModsConfig.xml integration for active mod tracking +- Real-time output display with scrollable text areas +- Local mod folder listing and comparison ## Installation @@ -18,6 +19,20 @@ A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod pip install -r requirements.txt ``` +## Configuration + +The application uses a `.env` file for configuration. You can modify the default settings: + +```env +# Default Collection URLs +CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585 +CONTENT_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712 +COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646 + +# ModsConfig.xml Path Template +MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml +``` + ## Usage 1. Run the application: @@ -25,16 +40,31 @@ A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod python steam_workshop_gui.py ``` -2. The application comes pre-populated with example collection URLs: - - **Core**: https://steamcommunity.com/workshop/filedetails/?id=3521297585 - - **Content**: steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712 - - **Cosmetics**: steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646 +2. **Set RimWorld Path**: + - Right-click RimWorld in Steam → Manage → Browse local files + - Copy and paste that path into the "RimWorld Game Folder" field + - The workshop path will automatically derive to `steamapps\workshop\content\294100` -3. You can modify these URLs or enter your own Steam Workshop collection URLs +3. **ModsConfig.xml**: + - The path is automatically detected + - You can manually edit if needed + - Click "Load Active Mods" to see currently enabled mods -4. Click "Extract Workshop IDs" to process the collections +4. **Collection Analysis**: + - Modify collection URLs or use defaults from .env + - Click "Extract Workshop IDs" to process collections + - See which mods are [INSTALLED] or [MISSING] -5. The right panel will display all the Workshop IDs found in each collection +5. **Local Mod Management**: + - Click "List Local Mod Folders" to see downloaded workshop mods + - Compare with collection requirements and active mods + +## File Structure + +- `steam_workshop_gui.py` - Main application +- `.env` - Configuration file +- `requirements.txt` - Python dependencies +- `README.md` - This file ## URL Formats Supported @@ -43,6 +73,8 @@ A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod ## Notes -- The application fetches collection data directly from Steam Workshop +- All paths are editable in the GUI +- Configuration URLs are loaded from .env file on startup +- Workshop path auto-derives from RimWorld path (steamapps\common\RimWorld → steamapps\workshop\content\294100) - Processing may take a few seconds depending on collection size -- Workshop IDs are extracted from the HTML content of collection pages \ No newline at end of file +- Workshop IDs are extracted from HTML content of collection pages \ No newline at end of file diff --git a/art/GameTitle.png b/art/GameTitle.png new file mode 100644 index 0000000..ed61383 Binary files /dev/null and b/art/GameTitle.png differ diff --git a/art/GameTitleOld.png b/art/GameTitleOld.png new file mode 100644 index 0000000..dd98ac4 Binary files /dev/null and b/art/GameTitleOld.png differ diff --git a/art/NotOwned_Anomaly.png b/art/NotOwned_Anomaly.png new file mode 100644 index 0000000..18f47cb Binary files /dev/null and b/art/NotOwned_Anomaly.png differ diff --git a/art/NotOwned_Biotech.png b/art/NotOwned_Biotech.png new file mode 100644 index 0000000..d841bb4 Binary files /dev/null and b/art/NotOwned_Biotech.png differ diff --git a/art/NotOwned_Ideology.png b/art/NotOwned_Ideology.png new file mode 100644 index 0000000..a13cdb6 Binary files /dev/null and b/art/NotOwned_Ideology.png differ diff --git a/art/NotOwned_Royalty.png b/art/NotOwned_Royalty.png new file mode 100644 index 0000000..e53bdee Binary files /dev/null and b/art/NotOwned_Royalty.png differ diff --git a/art/RimWordFont4.ttf b/art/RimWordFont4.ttf new file mode 100644 index 0000000..74fdf7a Binary files /dev/null and b/art/RimWordFont4.ttf differ diff --git a/art/hudsonriggssystems.png b/art/hudsonriggssystems.png new file mode 100644 index 0000000..29c094a Binary files /dev/null and b/art/hudsonriggssystems.png differ diff --git a/requirements.txt b/requirements.txt index 9e4b84c..2351805 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -requests>=2.25.1 \ No newline at end of file +requests>=2.25.1 +python-dotenv>=0.19.0 +pyglet>=1.5.0 \ No newline at end of file diff --git a/steam_workshop_gui.py b/steam_workshop_gui.py index 0e52b87..e84193c 100644 --- a/steam_workshop_gui.py +++ b/steam_workshop_gui.py @@ -1,12 +1,24 @@ import tkinter as tk -from tkinter import ttk, scrolledtext, filedialog, messagebox +from tkinter import ttk, scrolledtext, filedialog, messagebox, font import requests import re from urllib.parse import urlparse, parse_qs import threading import os -import winreg from pathlib import Path +import xml.etree.ElementTree as ET +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Try to import pyglet for font loading +try: + import pyglet + PYGLET_AVAILABLE = True +except ImportError: + PYGLET_AVAILABLE = False + print("pyglet not available - custom fonts may not work properly") class SteamWorkshopGUI: def __init__(self, root): @@ -15,11 +27,15 @@ class SteamWorkshopGUI: self.root.geometry("1200x800") self.root.configure(bg='#2b2b2b') + # Load custom font + self.load_custom_font() + # Configure dark theme self.setup_dark_theme() - # Initialize workshop folder + # Initialize paths self.workshop_folder = None + self.modsconfig_path = None # Create main frame main_frame = tk.Frame(root, bg='#2b2b2b') @@ -37,6 +53,110 @@ class SteamWorkshopGUI: self.create_input_section(left_frame) self.create_output_section(right_frame) + def load_custom_font(self): + """Load the RimWorld font using pyglet""" + try: + font_path = os.path.join("art", "RimWordFont4.ttf") + if os.path.exists(font_path): + abs_font_path = os.path.abspath(font_path) + print(f"Font file found: {abs_font_path}") + + if PYGLET_AVAILABLE: + try: + # Use pyglet to add the font + pyglet.font.add_file(abs_font_path) + + # Try to get the font family name from pyglet + # Load the font to get its actual family name + pyglet_font = pyglet.font.load(None, 12, bold=False, italic=False) + + # Common RimWorld font family names to try + possible_names = [ + "RimWorld", + "RimWorldFont", + "RimWordFont4", + "Calibri", + "Arial" + ] + + # Test each possible font name + for font_name in possible_names: + try: + test_font = font.Font(family=font_name, size=10) + # If we get here, the font worked + self.custom_font_family = font_name + self.custom_font_available = True + print(f"Successfully loaded font with pyglet: {font_name}") + return + except Exception as e: + continue + + print("pyglet loaded font but couldn't find working family name") + self.custom_font_available = False + + except Exception as e: + print(f"pyglet font loading failed: {e}") + self.custom_font_available = False + else: + print("pyglet not available, trying Windows registration...") + # Fall back to Windows registration method + try: + import ctypes + from ctypes import wintypes + + # Add font resource to Windows + gdi32 = ctypes.windll.gdi32 + result = gdi32.AddFontResourceW(abs_font_path) + + if result > 0: + print(f"Successfully registered font with Windows") + + # Notify Windows that fonts have changed + try: + HWND_BROADCAST = 0xFFFF + WM_FONTCHANGE = 0x001D + ctypes.windll.user32.SendMessageW(HWND_BROADCAST, WM_FONTCHANGE, 0, 0) + print("Sent font change notification to Windows") + except Exception as notify_error: + print(f"Could not send font change notification: {notify_error}") + + # For Windows registration, let's try using the actual font file name + # Sometimes the family name is different from what we expect + self.custom_font_family = "RimWorld" # Default assumption + self.custom_font_available = True + print(f"Using Windows-registered font: {self.custom_font_family}") + else: + print("Failed to register font with Windows") + self.custom_font_available = False + + except Exception as e: + print(f"Windows font registration error: {e}") + self.custom_font_available = False + + else: + print(f"Font file not found: {font_path}") + self.custom_font_available = False + + except Exception as e: + print(f"Error loading custom font: {e}") + self.custom_font_available = False + + def get_font(self, size=10, weight='normal'): + """Get font tuple for UI elements""" + if self.custom_font_available: + try: + # Use the registered font family name + font_obj = font.Font(family=self.custom_font_family, size=size, weight=weight) + print(f"Created font: {self.custom_font_family}, size={size}, weight={weight}") + return font_obj + except Exception as e: + print(f"Error using custom font: {e}") + # Fall back to a distinctive font so we can see the difference + return font.Font(family='Courier New', size=size, weight=weight) + else: + print(f"Using fallback font: Courier New, size={size}") + return font.Font(family='Courier New', size=size, weight=weight) + def setup_dark_theme(self): """Configure dark theme colors""" style = ttk.Style() @@ -51,7 +171,7 @@ class SteamWorkshopGUI: def create_input_section(self, parent): """Create the left input section""" title_label = tk.Label(parent, text="Steam Workshop Collections", - font=('Arial', 14, 'bold'), bg='#2b2b2b', fg='#ffffff') + font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff') title_label.pack(pady=(0, 10)) # RimWorld game folder input @@ -59,12 +179,12 @@ class SteamWorkshopGUI: rimworld_frame.pack(fill='x', pady=(0, 10)) rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:", - font=('Arial', 9, 'bold'), bg='#2b2b2b', fg='#ffffff') + font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff') rimworld_label.pack(anchor='w') rimworld_help = tk.Label(rimworld_frame, text="Right-click RimWorld in Steam > Manage > Browse local files, copy that path", - font=('Arial', 8), bg='#2b2b2b', fg='#888888', wraplength=380) + font=self.get_font(8), bg='#2b2b2b', fg='#888888', wraplength=380) rimworld_help.pack(anchor='w', pady=(2, 5)) rimworld_input_frame = tk.Frame(rimworld_frame, bg='#2b2b2b') @@ -72,64 +192,76 @@ class SteamWorkshopGUI: self.rimworld_var = tk.StringVar() self.rimworld_entry = tk.Entry(rimworld_input_frame, textvariable=self.rimworld_var, - font=('Arial', 9), bg='#404040', fg='#ffffff', + font=self.get_font(9), bg='#404040', fg='#ffffff', insertbackground='#ffffff', relief='flat', bd=5) self.rimworld_entry.pack(side='left', fill='x', expand=True) self.rimworld_entry.bind('', self.on_rimworld_path_change) browse_game_btn = tk.Button(rimworld_input_frame, text="Browse", command=self.browse_rimworld_folder, - bg='#505050', fg='white', font=('Arial', 8), + bg='#505050', fg='white', font=self.get_font(8), relief='flat', padx=10) browse_game_btn.pack(side='right', padx=(5, 0)) # Workshop folder display (derived from RimWorld path) workshop_frame = tk.Frame(parent, bg='#2b2b2b') - workshop_frame.pack(fill='x', pady=(0, 15)) + workshop_frame.pack(fill='x', pady=(0, 10)) workshop_label = tk.Label(workshop_frame, text="Workshop Folder (Auto-derived):", - font=('Arial', 9, 'bold'), bg='#2b2b2b', fg='#ffffff') + font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff') workshop_label.pack(anchor='w') self.workshop_var = tk.StringVar(value="Enter RimWorld path above") self.workshop_display = tk.Entry(workshop_frame, textvariable=self.workshop_var, - font=('Arial', 8), bg='#404040', fg='#ffffff', - insertbackground='#ffffff', relief='flat', bd=5, state='readonly') + font=self.get_font(8), bg='#404040', fg='#ffffff', + insertbackground='#ffffff', relief='flat', bd=5) self.workshop_display.pack(fill='x', pady=(5, 0)) + self.workshop_display.bind('', self.on_workshop_path_change) + + # ModsConfig.xml folder display + modsconfig_frame = tk.Frame(parent, bg='#2b2b2b') + modsconfig_frame.pack(fill='x', pady=(0, 15)) + + modsconfig_label = tk.Label(modsconfig_frame, text="ModsConfig.xml Path:", + font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff') + modsconfig_label.pack(anchor='w') + + self.modsconfig_var = tk.StringVar() + self.modsconfig_display = tk.Entry(modsconfig_frame, textvariable=self.modsconfig_var, + font=self.get_font(8), bg='#404040', fg='#ffffff', + insertbackground='#ffffff', relief='flat', bd=5) + self.modsconfig_display.pack(fill='x', pady=(5, 0)) + self.modsconfig_display.bind('', self.on_modsconfig_path_change) + + # Initialize ModsConfig path + self.find_modsconfig_path() # Core collection - self.create_url_input(parent, "Core Collection:", - "https://steamcommunity.com/workshop/filedetails/?id=3521297585") + core_url = os.getenv('CORE_COLLECTION_URL', 'https://steamcommunity.com/workshop/filedetails/?id=3521297585') + self.create_url_input(parent, "Core Collection:", core_url) # Content collection - self.create_url_input(parent, "Content Collection:", - "steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712") + content_url = os.getenv('CONTENT_COLLECTION_URL', 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712') + self.create_url_input(parent, "Content Collection:", content_url) # Cosmetics collection - self.create_url_input(parent, "Cosmetics Collection:", - "steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646") + cosmetics_url = os.getenv('COSMETICS_COLLECTION_URL', 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646') + self.create_url_input(parent, "Cosmetics Collection:", cosmetics_url) - # Process button - process_btn = tk.Button(parent, text="Extract Workshop IDs", - command=self.process_collections, - bg='#0078d4', fg='white', font=('Arial', 10, 'bold'), - relief='flat', padx=20, pady=8) - process_btn.pack(pady=10) - - # Check local mods button - local_btn = tk.Button(parent, text="List Local Mod Folders", - command=self.list_local_mod_folders, - bg='#28a745', fg='white', font=('Arial', 10, 'bold'), - relief='flat', padx=20, pady=8) - local_btn.pack(pady=5) + # Load Progression Pack button + load_btn = tk.Button(parent, text="Load Progression Pack Complete", + command=self.load_progression_pack, + bg='#0078d4', fg='white', font=self.get_font(12, 'bold'), + relief='flat', padx=30, pady=12) + load_btn.pack(pady=30) def create_url_input(self, parent, label_text, default_url): """Create a labeled URL input field""" - label = tk.Label(parent, text=label_text, font=('Arial', 10, 'bold'), + label = tk.Label(parent, text=label_text, font=self.get_font(10, 'bold'), bg='#2b2b2b', fg='#ffffff') label.pack(anchor='w', pady=(10, 5)) - entry = tk.Entry(parent, font=('Arial', 9), bg='#404040', fg='#ffffff', + entry = tk.Entry(parent, font=self.get_font(9), bg='#404040', fg='#ffffff', insertbackground='#ffffff', relief='flat', bd=5) entry.pack(fill='x', pady=(0, 10)) entry.insert(0, default_url) @@ -143,186 +275,26 @@ class SteamWorkshopGUI: def create_output_section(self, parent): """Create the right output section""" - # Main output area - main_output_frame = tk.Frame(parent, bg='#2b2b2b') - main_output_frame.pack(fill=tk.BOTH, expand=True) - - title_label = tk.Label(main_output_frame, text="Workshop IDs Output", - font=('Arial', 14, 'bold'), bg='#2b2b2b', fg='#ffffff') + title_label = tk.Label(parent, text="Workshop IDs and Package Names", + font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff') title_label.pack(pady=(0, 10)) # Output text area with scrollbar - self.output_text = scrolledtext.ScrolledText(main_output_frame, - font=('Consolas', 10), + self.output_text = scrolledtext.ScrolledText(parent, + font=self.get_font(10), bg='#1e1e1e', fg='#ffffff', insertbackground='#ffffff', selectbackground='#404040', - relief='flat', bd=5, height=20) + relief='flat', bd=5) self.output_text.pack(fill=tk.BOTH, expand=True) - # Local mod folders section - local_mods_frame = tk.Frame(parent, bg='#2b2b2b') - local_mods_frame.pack(fill=tk.X, pady=(10, 0)) - - local_title_label = tk.Label(local_mods_frame, text="Local Mod Folders", - font=('Arial', 12, 'bold'), bg='#2b2b2b', fg='#ffffff') - local_title_label.pack(pady=(0, 5)) - - # Local mods text area - self.local_mods_text = scrolledtext.ScrolledText(local_mods_frame, - font=('Consolas', 9), - bg='#1e1e1e', fg='#ffffff', - insertbackground='#ffffff', - selectbackground='#404040', - relief='flat', bd=5, height=8) - self.local_mods_text.pack(fill=tk.X) - - def extract_workshop_id(self, url): - """Extract workshop ID from Steam URL""" - # Clean up steam:// protocol URLs - if url.startswith('steam://openurl/'): - url = url.replace('steam://openurl/', '') - - # Extract ID from URL parameters - try: - parsed_url = urlparse(url) - if 'id=' in parsed_url.query: - query_params = parse_qs(parsed_url.query) - return query_params.get('id', [None])[0] - elif '/filedetails/?id=' in url: - # Direct extraction for simple cases - match = re.search(r'id=(\d+)', url) - if match: - return match.group(1) - except Exception as e: - print(f"Error extracting ID from {url}: {e}") - - return None - - def process_collections(self): - """Process all collection URLs and extract workshop IDs""" - self.output_text.delete(1.0, tk.END) - self.output_text.insert(tk.END, "Processing collections...\n\n") - self.root.update() - - # Process each collection in a separate thread to avoid blocking UI - threading.Thread(target=self._process_collections_thread, daemon=True).start() - - def _process_collections_thread(self): - """Thread function to process collections""" - try: - for collection_name, entry in self.url_entries.items(): - url = entry.get().strip() - if url: - self._safe_update_output(f"=== {collection_name.upper()} COLLECTION ===\n") - - workshop_id = self.extract_workshop_id(url) - if workshop_id: - self._safe_update_output(f"Collection ID: {workshop_id}\n") - - # Fetch collection contents - workshop_ids = self.fetch_collection_items(workshop_id) - if workshop_ids: - self._safe_update_output(f"Found {len(workshop_ids)} items:\n") - - # Check which mods are installed locally if workshop folder is available - local_mods = set() - if self.workshop_folder and os.path.exists(self.workshop_folder): - try: - local_mods = {item for item in os.listdir(self.workshop_folder) - if os.path.isdir(os.path.join(self.workshop_folder, item)) and item.isdigit()} - except: - pass - - missing_count = 0 - for item_id in workshop_ids: - status = " [INSTALLED]" if item_id in local_mods else " [MISSING]" - if item_id not in local_mods: - missing_count += 1 - self._safe_update_output(f"{item_id}{status}\n") - - if local_mods: - self._safe_update_output(f"\nSummary: {len(workshop_ids) - missing_count} installed, {missing_count} missing\n") - else: - self._safe_update_output("Could not fetch collection items\n") - else: - self._safe_update_output("Could not extract workshop ID from URL\n") - - self._safe_update_output("\n") - - self._safe_update_output("Processing complete!") - except Exception as e: - self._safe_update_output(f"Error in processing: {str(e)}\n") - - def find_steam_workshop_folder(self): - """Automatically find the Steam Workshop folder for RimWorld""" - try: - # Method 1: Try to find Steam installation path from registry - steam_paths = [] - - try: - # Check HKEY_LOCAL_MACHINE first - with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Valve\Steam") as key: - install_path = winreg.QueryValueEx(key, "InstallPath")[0] - steam_paths.append(install_path) - except (FileNotFoundError, OSError): - pass - - try: - # Check HKEY_CURRENT_USER - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Valve\Steam") as key: - install_path = winreg.QueryValueEx(key, "SteamPath")[0] - steam_paths.append(install_path) - except (FileNotFoundError, OSError): - pass - - # Method 2: Check common Steam installation locations - common_paths = [ - r"C:\Program Files (x86)\Steam", - r"C:\Program Files\Steam", - r"D:\Steam", - r"E:\Steam", - os.path.expanduser(r"~\Steam") - ] - steam_paths.extend(common_paths) - - # Method 3: Check if Steam is in PATH - try: - import shutil - steam_exe = shutil.which("steam.exe") - if steam_exe: - steam_paths.append(os.path.dirname(steam_exe)) - except: - pass - - # Look for the workshop folder in each potential Steam path - for steam_path in steam_paths: - if not steam_path: - continue - - workshop_path = os.path.join(steam_path, "steamapps", "workshop", "content", "294100") - if os.path.exists(workshop_path): - return workshop_path - - return None - - except Exception as e: - print(f"Error finding Steam workshop folder: {e}") - return None + def on_workshop_path_change(self, event=None): + """Called when workshop path is manually changed""" + self.workshop_folder = self.workshop_var.get().strip() - def browse_workshop_folder(self): - """Allow user to manually select the workshop folder""" - folder = filedialog.askdirectory( - title="Select RimWorld Workshop Folder (steamapps/workshop/content/294100)", - initialdir=self.workshop_folder or "C:\\" - ) - if folder: - # Validate that this looks like a workshop folder - if "294100" in folder or messagebox.askyesno( - "Confirm Folder", - f"Are you sure this is the RimWorld workshop folder?\n\n{folder}" - ): - self._safe_update_output(f"Workshop folder updated: {folder}\n") + def on_modsconfig_path_change(self, event=None): + """Called when ModsConfig path is manually changed""" + self.modsconfig_path = self.modsconfig_var.get().strip() def on_rimworld_path_change(self, event=None): """Called when RimWorld path is changed""" @@ -359,47 +331,357 @@ class SteamWorkshopGUI: self.rimworld_var.set(folder) self.on_rimworld_path_change() # Update workshop path - def list_local_mod_folders(self): - """List all local mod folder names in the separate text box""" + def find_modsconfig_path(self): + """Find the ModsConfig.xml file in AppData""" + try: + # Get path template from .env + path_template = os.getenv('MODSCONFIG_PATH_TEMPLATE', + r'%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml') + + # Expand environment variables + modsconfig_path = os.path.expandvars(path_template) + + if os.path.exists(modsconfig_path): + self.modsconfig_path = modsconfig_path + self.modsconfig_var.set(modsconfig_path) + return modsconfig_path + else: + self.modsconfig_var.set("ModsConfig.xml not found") + return None + + except Exception as e: + self.modsconfig_var.set(f"Error finding ModsConfig.xml: {str(e)}") + return None + + def extract_workshop_id(self, url): + """Extract workshop ID from Steam URL""" + # Clean up steam:// protocol URLs + if url.startswith('steam://openurl/'): + url = url.replace('steam://openurl/', '') + + # Extract ID from URL parameters + try: + parsed_url = urlparse(url) + if 'id=' in parsed_url.query: + query_params = parse_qs(parsed_url.query) + return query_params.get('id', [None])[0] + elif '/filedetails/?id=' in url: + # Direct extraction for simple cases + match = re.search(r'id=(\d+)', url) + if match: + return match.group(1) + except Exception as e: + print(f"Error extracting ID from {url}: {e}") + + return None + + def load_progression_pack(self): + """Load all progression pack collections and extract package names""" if not self.workshop_folder or not os.path.exists(self.workshop_folder): - self.local_mods_text.delete(1.0, tk.END) - self.local_mods_text.insert(tk.END, "Workshop folder not found or invalid.\nPlease enter correct RimWorld path above.") + self.output_text.delete(1.0, tk.END) + self.output_text.insert(tk.END, "Workshop folder not found!\nPlease enter the correct RimWorld path above.") return - threading.Thread(target=self._list_local_mod_folders_thread, daemon=True).start() + self.output_text.delete(1.0, tk.END) + self.output_text.insert(tk.END, "Loading Progression Pack Complete...\n\n") + + threading.Thread(target=self._load_progression_pack_thread, daemon=True).start() - def _list_local_mod_folders_thread(self): - """Thread function to list local mod folders""" + def _load_progression_pack_thread(self): + """Thread function to load progression pack""" try: - # Clear the local mods text box - self.local_mods_text.delete(1.0, tk.END) + all_workshop_ids = [] - if not os.path.exists(self.workshop_folder): - self.local_mods_text.insert(tk.END, f"Workshop folder does not exist:\n{self.workshop_folder}") - return + # Get collection URLs from entries + collections = { + "Core": self.url_entries['core'].get().strip(), + "Content": self.url_entries['content'].get().strip(), + "Cosmetics": self.url_entries['cosmetics'].get().strip() + } - # Get all subdirectories (each represents a workshop mod) - local_mods = [] - for item in os.listdir(self.workshop_folder): - item_path = os.path.join(self.workshop_folder, item) - if os.path.isdir(item_path) and item.isdigit(): - local_mods.append(item) + # Fetch all workshop IDs from collections + for collection_name, url in collections.items(): + if url: + self._safe_update_output(f"Fetching {collection_name} collection...\n") + workshop_id = self.extract_workshop_id(url) + if workshop_id: + workshop_ids = self.fetch_collection_items(workshop_id) + all_workshop_ids.extend(workshop_ids) + self._safe_update_output(f"Found {len(workshop_ids)} items in {collection_name}\n") - local_mods.sort() + # Remove duplicates + unique_ids = list(set(all_workshop_ids)) + self._safe_update_output(f"\nTotal unique workshop items: {len(unique_ids)}\n") + self._safe_update_output("Extracting package names from About.xml files...\n\n") - # Display in the local mods text box - self.local_mods_text.insert(tk.END, f"Found {len(local_mods)} local workshop mod folders:\n\n") - for mod_id in local_mods: - self.local_mods_text.insert(tk.END, f"{mod_id}\n") + # Extract package names from About.xml files + results = [] + for workshop_id in sorted(unique_ids): + package_name = self.get_package_name_from_about_xml(workshop_id) + results.append((workshop_id, package_name)) + + # Display results + self._safe_update_output("=== PROGRESSION PACK COMPLETE ===\n\n") + self._safe_update_output(f"{'Workshop ID':<15} | Package Name\n") + self._safe_update_output("-" * 80 + "\n") + + for workshop_id, package_name in results: + self._safe_update_output(f"{workshop_id:<15} | {package_name}\n") + + self._safe_update_output(f"\nTotal mods processed: {len(results)}\n") + + # Create ProgressionVanilla.rml file + self.create_rml_file(results) except Exception as e: - self.local_mods_text.delete(1.0, tk.END) - self.local_mods_text.insert(tk.END, f"Error listing local mods: {str(e)}") + self._safe_update_output(f"Error loading progression pack: {str(e)}\n") - def check_local_mods(self): - """Check which mods are installed locally in the workshop folder (for backward compatibility)""" - self.list_local_mod_folders() + def create_rml_file(self, mod_data): + """Create ProgressionVanilla.rml file with workshop IDs and names""" + try: + # Get ModLists directory path + modlists_path = os.path.expandvars(r"%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\ModLists") + + # Create directory if it doesn't exist + os.makedirs(modlists_path, exist_ok=True) + + rml_file_path = os.path.join(modlists_path, "ProgressionVanilla.rml") + + # Create XML content + xml_content = self.generate_rml_xml(mod_data) + + # Write to file + with open(rml_file_path, 'w', encoding='utf-8') as f: + f.write(xml_content) + + self._safe_update_output(f"\nProgressionVanilla.rml created successfully at:\n{rml_file_path}\n") + + except Exception as e: + self._safe_update_output(f"Error creating RML file: {str(e)}\n") + + def generate_rml_xml(self, mod_data): + """Generate the XML content for the .rml file""" + # Get core mods from ModsConfig.xml and RimWorld Data folder + core_mods = self.get_core_mods_from_config() + # Extract mod names from About.xml files for workshop mods + mod_names = [] + for workshop_id, package_id in mod_data: + mod_name = self.get_mod_name_from_about_xml(workshop_id) + mod_names.append(mod_name) + + xml_lines = [ + '', + '', + '\t', + '\t\t1.6.4633 rev1261', + '\t\t' + ] + + # Add core mods first + for package_id, mod_name in core_mods: + xml_lines.append(f'\t\t\t
  • {package_id}
  • ') + + # Add workshop mod package IDs to meta section + for workshop_id, package_id in mod_data: + xml_lines.append(f'\t\t\t
  • {package_id}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t\t' + ]) + + # Add core mods (they don't have Steam IDs, so use 0) + for package_id, mod_name in core_mods: + xml_lines.append(f'\t\t\t
  • 0
  • ') + + # Add workshop IDs to meta section + for workshop_id, package_id in mod_data: + xml_lines.append(f'\t\t\t
  • {workshop_id}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t\t' + ]) + + # Add core mod names first + for package_id, mod_name in core_mods: + xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') + + # Add workshop mod names to meta section + for mod_name in mod_names: + xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t', + '\t', + '\t\t' + ]) + + # Add core mods first to modList section + for package_id, mod_name in core_mods: + xml_lines.append(f'\t\t\t
  • {package_id}
  • ') + + # Add workshop mod package IDs to modList section + for workshop_id, package_id in mod_data: + xml_lines.append(f'\t\t\t
  • {package_id}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t\t' + ]) + + # Add core mod names first to modList section + for package_id, mod_name in core_mods: + xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') + + # Add workshop mod names to modList section + for mod_name in mod_names: + xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') + + xml_lines.extend([ + '\t\t
    ', + '\t
    ', + '
    ' + ]) + + return '\n'.join(xml_lines) + + def get_core_mods_from_config(self): + """Get core mods from ModsConfig.xml knownExpansions section and their info from RimWorld Data folder""" + core_mods = [] + + try: + if not self.modsconfig_path or not os.path.exists(self.modsconfig_path): + self._safe_update_output("ModsConfig.xml not found, using default core mods\n") + return [('ludeon.rimworld', 'RimWorld')] + + # Parse ModsConfig.xml + tree = ET.parse(self.modsconfig_path) + root = tree.getroot() + + # Get RimWorld Data folder path from the RimWorld game path + rimworld_data_path = None + if self.rimworld_var.get().strip(): + rimworld_game_path = self.rimworld_var.get().strip() + rimworld_data_path = os.path.join(rimworld_game_path, "Data") + + # Find knownExpansions section + known_expansions_element = root.find('knownExpansions') + if known_expansions_element is not None: + for li in known_expansions_element.findall('li'): + expansion_id = li.text + if expansion_id: + mod_name = self.get_core_mod_name(expansion_id.strip(), rimworld_data_path) + core_mods.append((expansion_id.strip(), mod_name)) + + if not core_mods: + # Fallback if no expansions found + core_mods = [('ludeon.rimworld', 'RimWorld')] + + self._safe_update_output(f"Found {len(core_mods)} core expansions from ModsConfig.xml\n") + + except Exception as e: + self._safe_update_output(f"Error reading core mods: {str(e)}\n") + core_mods = [('ludeon.rimworld', 'RimWorld')] + + return core_mods + + def get_core_mod_name(self, mod_id, rimworld_data_path): + """Get mod name from RimWorld Data folder About.xml""" + try: + if not rimworld_data_path or not os.path.exists(rimworld_data_path): + return self.get_default_mod_name(mod_id) + + # Look for mod folder in Data directory + # Core mods are usually in subfolders like Core, Royalty, etc. + possible_folders = [ + mod_id.replace('ludeon.rimworld.', '').replace('ludeon.rimworld', 'Core'), + mod_id.replace('ludeon.rimworld.', '').capitalize(), + mod_id.split('.')[-1].capitalize() if '.' in mod_id else mod_id + ] + + for folder_name in possible_folders: + about_xml_path = os.path.join(rimworld_data_path, folder_name, "About", "About.xml") + if os.path.exists(about_xml_path): + try: + tree = ET.parse(about_xml_path) + root = tree.getroot() + + name_element = root.find('name') + if name_element is not None and name_element.text: + return name_element.text.strip() + except ET.ParseError: + continue + + # If not found in Data folder, use default name + return self.get_default_mod_name(mod_id) + + except Exception as e: + return self.get_default_mod_name(mod_id) + + def get_default_mod_name(self, mod_id): + """Get default display name for core mods""" + default_names = { + 'ludeon.rimworld': 'RimWorld', + 'ludeon.rimworld.royalty': 'Royalty', + 'ludeon.rimworld.ideology': 'Ideology', + 'ludeon.rimworld.biotech': 'Biotech', + 'ludeon.rimworld.anomaly': 'Anomaly', + 'ludeon.rimworld.odyssey': 'Odyssey' + } + + return default_names.get(mod_id, mod_id) + + def get_mod_name_from_about_xml(self, workshop_id): + """Extract mod name from About/About.xml file""" + try: + about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml") + + if not os.path.exists(about_xml_path): + return f"Workshop ID {workshop_id}" + + # Parse the XML file + tree = ET.parse(about_xml_path) + root = tree.getroot() + + # Look for name element + name_element = root.find('name') + if name_element is not None and name_element.text: + return name_element.text.strip() + else: + return f"Workshop ID {workshop_id}" + + except ET.ParseError: + return f"Workshop ID {workshop_id}" + except Exception as e: + return f"Workshop ID {workshop_id}" + + def get_package_name_from_about_xml(self, workshop_id): + """Extract package name from About/About.xml file""" + try: + about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml") + + if not os.path.exists(about_xml_path): + return "About.xml not found" + + # Parse the XML file + tree = ET.parse(about_xml_path) + root = tree.getroot() + + # Look for packageId element + package_id_element = root.find('packageId') + if package_id_element is not None and package_id_element.text: + return package_id_element.text.strip() + else: + return "packageId not found" + + except ET.ParseError: + return "XML parse error" + except Exception as e: + return f"Error: {str(e)}" + def _safe_update_output(self, text): """Safely update output text without recursion""" try: @@ -484,14 +766,6 @@ class SteamWorkshopGUI: except Exception as e: self._safe_update_output(f"Error fetching collection: {str(e)}\n") return [] - - def _update_output(self, text): - """Update output text (called from main thread)""" - try: - self.output_text.insert(tk.END, text) - self.output_text.see(tk.END) - except Exception: - pass # Prevent recursion errors def main(): root = tk.Tk()