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\t0')
+
+ # 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()