import tkinter as tk from tkinter import ttk, scrolledtext, filedialog, messagebox, font import requests import re from urllib.parse import urlparse, parse_qs import threading import os from pathlib import Path import xml.etree.ElementTree as ET from dotenv import load_dotenv # Load environment variables load_dotenv() class SteamWorkshopGUI: def __init__(self, root): self.root = root self.root.title("Steam Workshop Collection Manager") self.root.geometry("1200x800") self.root.configure(bg='#2b2b2b') # Load custom font self.load_custom_font() # Configure dark theme self.setup_dark_theme() # Initialize paths self.workshop_folder = None self.modsconfig_path = None # Create main frame main_frame = tk.Frame(root, bg='#2b2b2b') main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Left panel for inputs left_frame = tk.Frame(main_frame, bg='#2b2b2b', width=400) left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) left_frame.pack_propagate(False) # Right panel for output right_frame = tk.Frame(main_frame, bg='#2b2b2b') right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) self.create_input_section(left_frame) self.create_output_section(right_frame) def load_custom_font(self): """Load the RimWorld font using Windows AddFontResourceEx with private flag""" try: font_path = os.path.join("art", "RimWordFont4.ttf") if os.path.exists(font_path): abs_font_path = os.path.abspath(font_path) # Use the Stack Overflow method with AddFontResourceEx success = self._load_font_private(abs_font_path) if success: # Use the actual font family name from the TTF file self.custom_font_family = "RimWordFont" self.custom_font_available = True return else: self.custom_font_available = False else: self.custom_font_available = False except Exception as e: self.custom_font_available = False # Initialize fallback values if font loading failed if not hasattr(self, 'custom_font_available'): self.custom_font_available = False if not hasattr(self, 'custom_font_family'): self.custom_font_family = None def _load_font_private(self, fontpath): """ Load font privately using AddFontResourceEx Based on Stack Overflow solution by Felipe """ try: from ctypes import windll, byref, create_unicode_buffer # Constants for AddFontResourceEx FR_PRIVATE = 0x10 # Font is private to this process FR_NOT_ENUM = 0x20 # Font won't appear in font enumeration # Create unicode buffer for the font path pathbuf = create_unicode_buffer(fontpath) # Use AddFontResourceExW for Unicode strings AddFontResourceEx = windll.gdi32.AddFontResourceExW # Set flags: private (unloaded when process dies) and not enumerable flags = FR_PRIVATE | FR_NOT_ENUM # Add the font resource numFontsAdded = AddFontResourceEx(byref(pathbuf), flags, 0) return bool(numFontsAdded) except Exception: return False def get_font(self, size=10, weight='normal'): """Get font tuple for UI elements""" if self.custom_font_available and self.custom_font_family: try: return font.Font(family=self.custom_font_family, size=size, weight=weight) except Exception: pass # Fall back to system font return font.Font(family='Arial', size=size, weight=weight) def setup_dark_theme(self): """Configure dark theme colors""" style = ttk.Style() style.theme_use('clam') # Configure colors for dark theme style.configure('TLabel', background='#2b2b2b', foreground='#ffffff') style.configure('TButton', background='#404040', foreground='#ffffff') style.map('TButton', background=[('active', '#505050')]) style.configure('TEntry', background='#404040', foreground='#ffffff', fieldbackground='#404040') def create_input_section(self, parent): """Create the left input section""" title_label = tk.Label(parent, text="Steam Workshop Collections", font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff') title_label.pack(pady=(0, 10)) # RimWorld game folder input rimworld_frame = tk.Frame(parent, bg='#2b2b2b') rimworld_frame.pack(fill='x', pady=(0, 10)) rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:", 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=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') rimworld_input_frame.pack(fill='x') self.rimworld_var = tk.StringVar() self.rimworld_entry = tk.Entry(rimworld_input_frame, textvariable=self.rimworld_var, 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=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, 10)) workshop_label = tk.Label(workshop_frame, text="Workshop Folder (Auto-derived):", 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=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 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 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 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) # 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=self.get_font(10, 'bold'), bg='#2b2b2b', fg='#ffffff') label.pack(anchor='w', pady=(10, 5)) 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) # Store reference to entry widgets if not hasattr(self, 'url_entries'): self.url_entries = {} collection_name = label_text.replace(" Collection:", "").lower() self.url_entries[collection_name] = entry def create_output_section(self, parent): """Create the right output section""" 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(parent, font=self.get_font(10), bg='#1e1e1e', fg='#ffffff', insertbackground='#ffffff', selectbackground='#404040', relief='flat', bd=5) self.output_text.pack(fill=tk.BOTH, expand=True) def on_workshop_path_change(self, event=None): """Called when workshop path is manually changed""" self.workshop_folder = self.workshop_var.get().strip() 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""" rimworld_path = self.rimworld_var.get().strip() if rimworld_path: # Derive workshop path from RimWorld path # From: D:\SteamLibrary\steamapps\common\RimWorld # To: D:\SteamLibrary\steamapps\workshop\content\294100 if "steamapps" in rimworld_path.lower(): # Find the steamapps part and replace common\RimWorld with workshop\content\294100 parts = rimworld_path.split(os.sep) try: steamapps_index = next(i for i, part in enumerate(parts) if part.lower() == 'steamapps') # Build new path: everything up to steamapps + steamapps + workshop + content + 294100 workshop_parts = parts[:steamapps_index + 1] + ['workshop', 'content', '294100'] workshop_path = os.sep.join(workshop_parts) self.workshop_folder = workshop_path self.workshop_var.set(workshop_path) except (StopIteration, IndexError): self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") else: self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") else: self.workshop_var.set("Enter RimWorld path above") def browse_rimworld_folder(self): """Allow user to browse for RimWorld game folder""" folder = filedialog.askdirectory( title="Select RimWorld Game Folder (Right-click RimWorld in Steam > Manage > Browse local files)", initialdir="C:\\" ) if folder: self.rimworld_var.set(folder) self.on_rimworld_path_change() # Update workshop path 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.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 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 _load_progression_pack_thread(self): """Thread function to load progression pack""" try: all_workshop_ids = [] # 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() } # 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") # 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") # 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._safe_update_output(f"Error loading progression pack: {str(e)}\n") 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: self.output_text.insert(tk.END, text) self.output_text.see(tk.END) except Exception: pass # Ignore update errors to prevent recursion def fetch_collection_items(self, collection_id): """Fetch workshop IDs from a Steam Workshop collection""" try: # Steam Workshop collection URL url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={collection_id}" headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } self._safe_update_output(f"Fetching collection page...\n") response = requests.get(url, headers=headers, timeout=15) response.raise_for_status() # Extract workshop IDs from collection items only html_content = response.text workshop_ids = [] # Find the collectionChildren section collection_start = html_content.find('
    ') if collection_start != -1: # Find the proper end of the collectionChildren section # Look for the closing
    that matches the opening collectionChildren div # We need to count div tags to find the matching closing tag search_pos = collection_start + len('
    ') div_count = 1 # We've opened one div collection_end = -1 while search_pos < len(html_content) and div_count > 0: next_open = html_content.find('', search_pos) if next_close == -1: break if next_open != -1 and next_open < next_close: div_count += 1 search_pos = next_open + 4 else: div_count -= 1 if div_count == 0: collection_end = next_close + 6 break search_pos = next_close + 6 if collection_end == -1: collection_end = len(html_content) collection_section = html_content[collection_start:collection_end] # Extract IDs from sharedfile_ elements (these are the actual collection items) sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', collection_section) workshop_ids.extend(sharedfile_ids) self._safe_update_output(f"Found {len(sharedfile_ids)} collection items\n") else: # Fallback: search the entire page but be more selective self._safe_update_output("Collection section not found, using fallback method\n") sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', html_content) workshop_ids.extend(sharedfile_ids) # Remove duplicates and the collection ID itself unique_ids = list(set(workshop_ids)) if collection_id in unique_ids: unique_ids.remove(collection_id) return sorted(unique_ids) # Sort for consistent output except requests.RequestException as e: self._safe_update_output(f"Network error: {str(e)}\n") return [] except Exception as e: self._safe_update_output(f"Error fetching collection: {str(e)}\n") return [] def main(): root = tk.Tk() app = SteamWorkshopGUI(root) root.mainloop() if __name__ == "__main__": main()