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 import sys from pathlib import Path import xml.etree.ElementTree as ET from dotenv import load_dotenv from PIL import Image, ImageTk, ImageDraw, ImageFilter import time import webbrowser from update_checker import UpdateChecker from update_config import get_update_config # Load environment variables load_dotenv() def get_resource_path(relative_path): """Get absolute path to resource, works for dev and for PyInstaller""" try: # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS except Exception: base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) class LoadingScreen: def __init__(self, root, on_complete_callback): self.root = root self.on_complete_callback = on_complete_callback self.current_stage = 0 self.expansion_check_results = {} # Load custom font first self.load_custom_font() # Create fullscreen loading window self.loading_window = tk.Toplevel(root) self.loading_window.title("Progression: Loader") self.loading_window.configure(bg='#2b2b2b') # Set window icon try: icon_path = get_resource_path(get_resource_path(os.path.join("art", "Progression.ico"))) if os.path.exists(icon_path): self.loading_window.iconbitmap(icon_path) except Exception as e: print(f"Could not load icon for loading window: {e}") self.loading_window.attributes('-topmost', True) # Make it fullscreen self.loading_window.state('zoomed') self.loading_window.protocol("WM_DELETE_WINDOW", self.on_close) # Load images self.load_images() # Create UI elements self.create_loading_ui() # Start the loading sequence self.start_loading_sequence() def load_custom_font(self): """Load the RimWorld font using Windows AddFontResourceEx with private flag""" try: font_path = get_resource_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 create_radial_glow_image(self, base_image): """Create a radial glow effect around the image with #ff8686 color""" # Get base image dimensions base_width, base_height = base_image.size # Create larger canvas for glow effect glow_padding = 15 glow_width = base_width + (glow_padding * 2) glow_height = base_height + (glow_padding * 2) # Create glow background matching the label background glow_bg = Image.new('RGBA', (glow_width, glow_height), (43, 43, 43, 255)) # #2b2b2b background # Create radial glow mask glow_mask = Image.new('L', (glow_width, glow_height), 0) draw = ImageDraw.Draw(glow_mask) # Calculate center and create multiple circles for smooth gradient center_x, center_y = glow_width // 2, glow_height // 2 # Create glow that extends beyond the original image max_radius = glow_padding + max(base_width, base_height) // 2 # Create radial gradient by drawing concentric circles for i in range(max_radius, 0, -1): # Calculate alpha based on distance from center (stronger in center) # Make the glow more intense closer to the logo if i <= max(base_width, base_height) // 2: # Inner area (around the logo) - stronger glow alpha = int(255 * 0.8) else: # Outer area - fade out fade_factor = (max_radius - i) / (max_radius - max(base_width, base_height) // 2) alpha = int(255 * fade_factor * 0.6) if alpha > 0: draw.ellipse([center_x - i, center_y - i, center_x + i, center_y + i], fill=alpha) # Apply blur for smooth glow effect glow_mask = glow_mask.filter(ImageFilter.GaussianBlur(radius=6)) # Create glow color layer (#ff8686) glow_color = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 0)) glow_layer = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 255)) # Apply the mask to create the glow effect glow_color.paste(glow_layer, mask=glow_mask) # Composite glow onto background glow_bg = Image.alpha_composite(glow_bg, glow_color) # Convert base image to RGBA if needed if base_image.mode != 'RGBA': base_image = base_image.convert('RGBA') # Paste the original image in the exact center to maintain position paste_x = (glow_width - base_width) // 2 paste_y = (glow_height - base_height) // 2 glow_bg.paste(base_image, (paste_x, paste_y), base_image) return ImageTk.PhotoImage(glow_bg) def load_images(self): """Load all required images""" try: # Load game title self.game_title_img = Image.open(get_resource_path("art/GameTitle.png")) self.game_title_tk = ImageTk.PhotoImage(self.game_title_img) # Load HR Systems logo self.hr_logo_img = Image.open(get_resource_path("art/hudsonriggssystems.png")) # Resize HR logo to be smaller hr_width, hr_height = self.hr_logo_img.size new_hr_height = 60 new_hr_width = int((new_hr_height / hr_height) * hr_width) self.hr_logo_img = self.hr_logo_img.resize((new_hr_width, new_hr_height), Image.Resampling.LANCZOS) self.hr_logo_tk = ImageTk.PhotoImage(self.hr_logo_img) # Create radial glow effect version for loading screen self.hr_logo_glow_tk = self.create_radial_glow_image(self.hr_logo_img) # Load expansion images - dynamically find all NotOwned_ files self.expansion_images = {} art_dir = get_resource_path("art") if os.path.exists(art_dir): for file in os.listdir(art_dir): if file.startswith("NotOwned_") and file.endswith(".png"): try: img = Image.open(os.path.join(art_dir, file)) # Resize expansion images img_width, img_height = img.size new_height = 150 new_width = int((new_height / img_height) * img_width) img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) expansion_name = file.replace("NotOwned_", "").replace(".png", "") self.expansion_images[expansion_name] = ImageTk.PhotoImage(img) except Exception as e: print(f"Error loading {file}: {e}") except Exception as e: print(f"Error loading images: {e}") # Create placeholder images if loading fails self.game_title_tk = None self.hr_logo_tk = None self.expansion_images = {} # Create placeholder images if loading fails self.game_title_tk = None self.hr_logo_tk = None self.expansion_images = {} def create_loading_ui(self): """Create the loading screen UI""" # Main container self.main_frame = tk.Frame(self.loading_window, bg='#2b2b2b') self.main_frame.pack(fill=tk.BOTH, expand=True) # Game title (initially large and centered) self.title_label = tk.Label(self.main_frame, bg='#2b2b2b') if self.game_title_tk: self.title_label.configure(image=self.game_title_tk) else: self.title_label.configure(text="Progression: Loader", fg='white', font=self.get_font(24, 'bold')) self.title_label.pack(expand=True) # HR Systems logo (initially at bottom) with space for glow effect hr_container = tk.Frame(self.main_frame, bg='#2b2b2b') hr_container.pack(side=tk.BOTTOM, pady=40) # Increased padding for glow space self.hr_logo_label = tk.Label(hr_container, bg='#2b2b2b', cursor='hand2') if self.hr_logo_tk: # Calculate fixed size for the label to accommodate glow glow_padding = 15 # Get original image size (60x height from load_images) base_width = 60 * 3 # Approximate width based on typical logo proportions base_height = 60 label_width = base_width + (glow_padding * 2) label_height = base_height + (glow_padding * 2) self.hr_logo_label.configure(image=self.hr_logo_tk, width=label_width, height=label_height, compound='center') else: self.hr_logo_label.configure(text="Hudson Riggs Systems", fg='#888888', font=self.get_font(12)) # Make HR logo clickable in loading screen self.hr_logo_label.bind('', self.open_hr_website) self.hr_logo_label.bind('', self.on_loading_hr_enter) self.hr_logo_label.bind('', self.on_loading_hr_leave) self.hr_logo_label.pack() # Content frame (initially hidden) self.content_frame = tk.Frame(self.main_frame, bg='#2b2b2b') # Expansion check frame self.expansion_frame = tk.Frame(self.content_frame, bg='#2b2b2b') # Collection subscription frame self.subscription_frame = tk.Frame(self.content_frame, bg='#2b2b2b') # Final confirmation frame self.final_frame = tk.Frame(self.content_frame, bg='#2b2b2b') def start_loading_sequence(self): """Start the loading animation sequence""" # Stage 1: Show initial screen for 2 seconds self.loading_window.after(2000, self.stage_2_move_title) def stage_2_move_title(self): """Stage 2: Move title to top and show expansion check""" # Start the smooth animation to move title to top self.animate_title_to_top_smooth() # After animation completes, check expansions self.loading_window.after(1500, self.stage_3_check_subscriptions) # Updated stage number def animate_title_to_top_smooth(self): """Smoothly animate the title moving to the top of the screen""" # Get current window dimensions window_width = self.loading_window.winfo_width() window_height = self.loading_window.winfo_height() # If window dimensions aren't available yet, wait and try again if window_width <= 1 or window_height <= 1: self.loading_window.after(100, self.animate_title_to_top_smooth) return # Remove from pack layout and switch to place for animation self.title_label.pack_forget() # Get current title image dimensions if hasattr(self, 'game_title_tk') and self.game_title_tk: # Calculate current and target sizes current_img = self.game_title_img current_width, current_height = current_img.size # Target size (smaller for header) target_height = 80 target_width = int((target_height / current_height) * current_width) # Animation parameters start_x = window_width // 2 start_y = window_height // 2 target_x = window_width // 2 target_y = 60 # Top position # Start the smooth animation self.animate_title_step(0, 30, start_x, start_y, target_x, target_y, current_width, current_height, target_width, target_height) else: # Fallback for text version self.animate_text_title_smooth() def animate_title_step(self, step, total_steps, start_x, start_y, target_x, target_y, start_width, start_height, target_width, target_height): """Perform one step of the title animation""" if step <= total_steps: # Calculate progress (0.0 to 1.0) progress = step / total_steps # Use easing function for smooth animation (ease-out) eased_progress = 1 - (1 - progress) ** 3 # Calculate current position current_x = start_x + (target_x - start_x) * eased_progress current_y = start_y + (target_y - start_y) * eased_progress # Calculate current size current_width = start_width + (target_width - start_width) * eased_progress current_height = start_height + (target_height - start_height) * eased_progress # Resize image for current step if hasattr(self, 'game_title_img'): resized_img = self.game_title_img.resize( (int(current_width), int(current_height)), Image.Resampling.LANCZOS ) current_tk_img = ImageTk.PhotoImage(resized_img) self.title_label.configure(image=current_tk_img) # Keep reference to prevent garbage collection self.title_label.image = current_tk_img # Position the label self.title_label.place(x=current_x, y=current_y, anchor='center') # Schedule next step self.loading_window.after(50, lambda: self.animate_title_step( step + 1, total_steps, start_x, start_y, target_x, target_y, start_width, start_height, target_width, target_height )) else: # Animation complete - finalize position and switch back to pack self.finalize_title_position() def animate_text_title_smooth(self): """Animate text title if image is not available""" window_width = self.loading_window.winfo_width() window_height = self.loading_window.winfo_height() start_x = window_width // 2 start_y = window_height // 2 target_x = window_width // 2 target_y = 60 # Animate text title position and size self.animate_text_step(0, 30, start_x, start_y, target_x, target_y, 24, 16) def animate_text_step(self, step, total_steps, start_x, start_y, target_x, target_y, start_size, target_size): """Animate text title step by step""" if step <= total_steps: progress = step / total_steps eased_progress = 1 - (1 - progress) ** 3 current_x = start_x + (target_x - start_x) * eased_progress current_y = start_y + (target_y - start_y) * eased_progress current_size = start_size + (target_size - start_size) * eased_progress # Update font size self.title_label.configure(font=self.get_font(int(current_size), 'bold')) self.title_label.place(x=current_x, y=current_y, anchor='center') self.loading_window.after(50, lambda: self.animate_text_step( step + 1, total_steps, start_x, start_y, target_x, target_y, start_size, target_size )) else: self.finalize_title_position() def finalize_title_position(self): """Finalize the title position after animation and show content""" # Remove from place and switch back to pack layout self.title_label.place_forget() # Set final image size if hasattr(self, 'game_title_img'): final_height = 80 final_width = int((final_height / self.game_title_img.size[1]) * self.game_title_img.size[0]) final_img = self.game_title_img.resize((final_width, final_height), Image.Resampling.LANCZOS) self.small_title_tk = ImageTk.PhotoImage(final_img) self.title_label.configure(image=self.small_title_tk) else: self.title_label.configure(font=self.get_font(16, 'bold')) # Pack at top self.title_label.pack(side=tk.TOP, pady=20) # Show content frame self.content_frame.pack(fill=tk.BOTH, expand=True, padx=50, pady=20) def stage_3_check_subscriptions(self): """Stage 3: Check Steam Workshop subscriptions (renamed from stage_4)""" # Show expansion check frame self.expansion_frame.pack(fill=tk.BOTH, expand=True) # Add title check_title = tk.Label(self.expansion_frame, text="Checking RimWorld Expansions...", fg='white', bg='#2b2b2b', font=self.get_font(18, 'bold')) check_title.pack(pady=20) # Check expansions in a separate thread threading.Thread(target=self.check_expansions_thread, daemon=True).start() def check_expansions_thread(self): """Check which expansions are owned""" try: # Find ModsConfig.xml modsconfig_path = self.find_modsconfig_path() if not modsconfig_path or not os.path.exists(modsconfig_path): # If no ModsConfig found, get all NotOwned_ files and assume all are missing art_dir = get_resource_path("art") not_owned_files = [] if os.path.exists(art_dir): for file in os.listdir(art_dir): if file.startswith("NotOwned_") and file.endswith(".png"): expansion_name = file.replace("NotOwned_", "").replace(".png", "") not_owned_files.append(expansion_name) self.expansion_check_results = {expansion: False for expansion in not_owned_files} else: # Parse ModsConfig.xml to check known expansions self.expansion_check_results = self.parse_known_expansions(modsconfig_path) # Update UI on main thread self.loading_window.after(0, self.show_expansion_results) except Exception as e: print(f"Error checking expansions: {e}") # Assume all missing on error - get from NotOwned_ files art_dir = get_resource_path("art") not_owned_files = [] if os.path.exists(art_dir): for file in os.listdir(art_dir): if file.startswith("NotOwned_") and file.endswith(".png"): expansion_name = file.replace("NotOwned_", "").replace(".png", "") not_owned_files.append(expansion_name) self.expansion_check_results = {expansion: False for expansion in not_owned_files} self.loading_window.after(0, self.show_expansion_results) def find_modsconfig_path(self): """Find ModsConfig.xml file""" try: path_template = os.getenv('MODSCONFIG_PATH_TEMPLATE', r'%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml') modsconfig_path = os.path.expandvars(path_template) if os.path.exists(modsconfig_path): return modsconfig_path return None except Exception: return None def parse_known_expansions(self, modsconfig_path): """Parse ModsConfig.xml to check which expansions are owned""" # Get all NotOwned_ PNG files from art directory art_dir = get_resource_path("art") not_owned_files = [] if os.path.exists(art_dir): for file in os.listdir(art_dir): if file.startswith("NotOwned_") and file.endswith(".png"): # Extract expansion name (remove NotOwned_ prefix and .png suffix) expansion_name = file.replace("NotOwned_", "").replace(".png", "") not_owned_files.append(expansion_name) # Initialize results - all expansions are missing by default results = {expansion: False for expansion in not_owned_files} try: tree = ET.parse(modsconfig_path) root = tree.getroot() # Get RimWorld Data folder path from the RimWorld game path rimworld_data_path = None if hasattr(self, 'rimworld_var') and self.rimworld_var.get().strip(): rimworld_game_path = self.rimworld_var.get().strip() rimworld_data_path = os.path.join(rimworld_game_path, "Data") known_expansions = root.find('knownExpansions') if known_expansions is not None: for li in known_expansions.findall('li'): expansion_id = li.text if expansion_id: # Get the real expansion name from RimWorld Data folder real_name = self.get_expansion_real_name(expansion_id.strip(), rimworld_data_path) # Check if this real name matches any of our NotOwned_ files for expansion in not_owned_files: if real_name and expansion.lower() == real_name.lower(): results[expansion] = True break except Exception as e: print(f"Error parsing ModsConfig.xml: {e}") return results def get_expansion_real_name(self, expansion_id, rimworld_data_path): """Get the real expansion name from RimWorld Data folder About.xml""" try: if not rimworld_data_path or not os.path.exists(rimworld_data_path): # Fallback to extracting from ID if no data path return self.extract_name_from_id(expansion_id) # Look for expansion folder in Data directory # Core expansions are usually in subfolders like Royalty, Ideology, etc. possible_folders = [ expansion_id.replace('ludeon.rimworld.', '').capitalize(), expansion_id.split('.')[-1].capitalize() if '.' in expansion_id else expansion_id, expansion_id.replace('ludeon.rimworld.', ''), expansion_id.replace('ludeon.rimworld', 'Core') ] 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 fallback return self.extract_name_from_id(expansion_id) except Exception as e: return self.extract_name_from_id(expansion_id) def extract_name_from_id(self, expansion_id): """Extract expansion name from ID as fallback""" if 'royalty' in expansion_id.lower(): return 'Royalty' elif 'ideology' in expansion_id.lower(): return 'Ideology' elif 'biotech' in expansion_id.lower(): return 'Biotech' elif 'anomaly' in expansion_id.lower(): return 'Anomaly' elif 'odyssey' in expansion_id.lower(): return 'Odyssey' else: # Extract the last part after the last dot and capitalize parts = expansion_id.split('.') if len(parts) > 1: return parts[-1].capitalize() return expansion_id def show_expansion_results(self): """Show the results of expansion checking""" # Clear expansion frame for widget in self.expansion_frame.winfo_children(): widget.destroy() missing_expansions = [name for name, owned in self.expansion_check_results.items() if not owned] if not missing_expansions: # All expansions owned, proceed to subscription check success_label = tk.Label(self.expansion_frame, text="✓ All RimWorld expansions detected!", fg='#00ff00', bg='#2b2b2b', font=self.get_font(16, 'bold')) success_label.pack(pady=20) self.loading_window.after(2000, self.stage_6_final_confirmation) else: # Show missing expansions error_title = tk.Label(self.expansion_frame, text=f"You don't own {', '.join(missing_expansions)}!", fg='#ff4444', bg='#2b2b2b', font=self.get_font(16, 'bold')) error_title.pack(pady=20) # Show expansion images side by side images_frame = tk.Frame(self.expansion_frame, bg='#2b2b2b') images_frame.pack(pady=20) for expansion in missing_expansions: if expansion in self.expansion_images: img_label = tk.Label(images_frame, image=self.expansion_images[expansion], bg='#2b2b2b') img_label.pack(side=tk.LEFT, padx=10) # Cannot continue message cannot_continue = tk.Label(self.expansion_frame, text="Cannot continue!", fg='#ff4444', bg='#2b2b2b', font=self.get_font(14, 'bold')) cannot_continue.pack(pady=20) # Exit button exit_btn = tk.Button(self.expansion_frame, text="Exit Application", command=self.exit_application, bg='#ff4444', fg='white', font=self.get_font(12, 'bold'), padx=30, pady=10) exit_btn.pack(pady=20) def stage_3_check_subscriptions(self): """Stage 3: Check Steam Workshop subscriptions""" # Show subscription check frame self.subscription_frame.pack(fill=tk.BOTH, expand=True) # Add title sub_title = tk.Label(self.subscription_frame, text="Steam Workshop Collections", fg='white', bg='#2b2b2b', font=self.get_font(18, 'bold')) sub_title.pack(pady=20) # Collection checkboxes collections = [ ("Core Collection", "https://steamcommunity.com/workshop/filedetails/?id=3521297585"), ("Content Collection", "https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712"), ("Cosmetics Collection", "https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646") ] self.subscription_vars = {} for name, url in collections: frame = tk.Frame(self.subscription_frame, bg='#2b2b2b') frame.pack(pady=10, padx=50, fill='x') var = tk.BooleanVar() self.subscription_vars[name] = var # Create a frame for checkbox and link checkbox_frame = tk.Frame(frame, bg='#2b2b2b') checkbox_frame.pack(fill='x') checkbox = tk.Checkbutton(checkbox_frame, text=f"I have subscribed to the ", variable=var, fg='white', bg='#2b2b2b', selectcolor='#404040', font=self.get_font(12), activebackground='#2b2b2b', activeforeground='white') checkbox.pack(side='left', anchor='w') # Create clickable link for collection name collection_link = tk.Label(checkbox_frame, text=name, fg='#4da6ff', # Light blue color for links bg='#2b2b2b', font=self.get_font(12, 'bold'), cursor='hand2') collection_link.pack(side='left') # Bind click event to open collection URL collection_link.bind('', lambda e, url=url: self.open_collection_url(url)) collection_link.bind('', lambda e, label=collection_link: self.on_link_enter(label)) collection_link.bind('', lambda e, label=collection_link: self.on_link_leave(label)) # Continue button continue_btn = tk.Button(self.subscription_frame, text="Continue", command=self.check_all_subscribed, bg='#0078d4', fg='white', font=self.get_font(12, 'bold'), padx=30, pady=10) continue_btn.pack(pady=30) def check_all_subscribed(self): """Check if all collections are subscribed""" all_checked = all(var.get() for var in self.subscription_vars.values()) if not all_checked: self.show_subscription_warning() return # All subscribed, proceed to RimWorld download check self.stage_4_rimworld_check() def show_subscription_warning(self): """Show subscription warning in custom styled window""" # Create warning window warning_window = tk.Toplevel(self.loading_window) warning_window.title("Progression: Loader - Subscription Required") warning_window.configure(bg='#2b2b2b') # Set window icon try: icon_path = get_resource_path(os.path.join("art", "Progression.ico")) if os.path.exists(icon_path): warning_window.iconbitmap(icon_path) except Exception: pass # Ignore icon loading errors for popup windows warning_window.attributes('-topmost', True) # Warning text warning_text = """Steam Workshop Collections Required You must subscribe to all three collections before continuing: • Core Collection - Essential progression mods • Content Collection - Expanded content and features • Cosmetics Collection - Visual enhancements and aesthetics To subscribe to a collection: 1. Click on the collection name link (blue text) 2. Click "Subscribe" on the Steam Workshop page 3. Wait for Steam to download the collection 4. Return here and check the subscription box 5. Repeat for all three collections All collections are required for the complete Progression experience.""" # Create a temporary label to measure text size temp_label = tk.Label(warning_window, text=warning_text, font=self.get_font(11), justify='left', wraplength=550) temp_label.update_idletasks() # Get the required text dimensions text_width = temp_label.winfo_reqwidth() text_height = temp_label.winfo_reqheight() # Destroy temporary label temp_label.destroy() # Calculate window size (25% larger than text + padding for title and button) window_width = int(text_width * 1.25) + 60 # 25% larger + padding window_height = int(text_height * 1.25) + 140 # 25% larger + space for title and button # Set the calculated window size warning_window.geometry(f"{window_width}x{window_height}") # Center the window on screen warning_window.update_idletasks() x = (warning_window.winfo_screenwidth() // 2) - (window_width // 2) y = (warning_window.winfo_screenheight() // 2) - (window_height // 2) warning_window.geometry(f"{window_width}x{window_height}+{x}+{y}") # Title title_label = tk.Label(warning_window, text="⚠️ Subscription Required", fg='#ff8686', bg='#2b2b2b', font=self.get_font(16, 'bold')) title_label.pack(pady=20) # Warning label with proper sizing warning_label = tk.Label(warning_window, text=warning_text, fg='#cccccc', bg='#2b2b2b', font=self.get_font(11), justify='left', wraplength=text_width) # Use measured width warning_label.pack(pady=20, padx=30, expand=True) # Close button close_btn = tk.Button(warning_window, text="I Understand", command=warning_window.destroy, bg='#ff8686', fg='white', font=self.get_font(12, 'bold'), padx=30, pady=10) close_btn.pack(pady=20) def stage_4_rimworld_check(self): """Stage 4: Check RimWorld download and launch status""" # Hide subscription frame, show RimWorld check frame self.subscription_frame.pack_forget() # Create RimWorld check frame self.rimworld_check_frame = tk.Frame(self.content_frame, bg='#2b2b2b') self.rimworld_check_frame.pack(fill=tk.BOTH, expand=True) # Add title check_title = tk.Label(self.rimworld_check_frame, text="RimWorld Setup Verification", fg='white', bg='#2b2b2b', font=self.get_font(18, 'bold')) check_title.pack(pady=30) # Add instructions instructions = tk.Label(self.rimworld_check_frame, text="Please ensure the following before continuing:", fg='#cccccc', bg='#2b2b2b', font=self.get_font(14)) instructions.pack(pady=10) # Create checklist checklist_frame = tk.Frame(self.rimworld_check_frame, bg='#2b2b2b') checklist_frame.pack(pady=20) checklist_items = [ "✓ RimWorld has finished downloading completely", "✓ You have launched RimWorld at least once", "✓ The game runs without errors", "✓ All required DLCs are installed and activated" ] for item in checklist_items: item_label = tk.Label(checklist_frame, text=item, fg='#00ff00', bg='#2b2b2b', font=self.get_font(12), anchor='w') item_label.pack(fill='x', pady=5, padx=50) # Add confirmation message confirm_message = tk.Label(self.rimworld_check_frame, text="Have you completed all the above requirements?", fg='white', bg='#2b2b2b', font=self.get_font(14, 'bold')) confirm_message.pack(pady=30) # Buttons frame buttons_frame = tk.Frame(self.rimworld_check_frame, bg='#2b2b2b') buttons_frame.pack(pady=20) # Yes Mother button yes_btn = tk.Button(buttons_frame, text="Yes Mother", command=self.proceed_to_dlc_check, bg='#00aa00', fg='white', font=self.get_font(12, 'bold'), padx=30, pady=10) yes_btn.pack(side=tk.LEFT, padx=10) # Not Yet button not_yet_btn = tk.Button(buttons_frame, text="Not Yet", command=self.show_setup_instructions, bg='#aa6600', fg='white', font=self.get_font(12, 'bold'), padx=30, pady=10) not_yet_btn.pack(side=tk.LEFT, padx=10) def proceed_to_dlc_check(self): """Proceed to DLC check after RimWorld verification""" # Hide RimWorld check frame and proceed to expansion check self.rimworld_check_frame.pack_forget() self.stage_5_check_expansions() def show_setup_instructions(self): """Show setup instructions if user is not ready""" # Create instruction window instruction_window = tk.Toplevel(self.loading_window) instruction_window.title("Progression: Loader - Setup Instructions") instruction_window.configure(bg='#2b2b2b') # Set window icon try: icon_path = get_resource_path(os.path.join("art", "Progression.ico")) if os.path.exists(icon_path): instruction_window.iconbitmap(icon_path) except Exception: pass # Ignore icon loading errors for popup windows instruction_window.attributes('-topmost', True) # Instructions text instructions_text = """To properly set up RimWorld: 1. Download RimWorld from Steam • Right-click RimWorld in your Steam library • Select "Install" if not already installed • Wait for download to complete (may take several minutes) 2. Launch RimWorld at least once • Click "Play" in Steam or run RimWorld.exe • Let the game load to the main menu • This creates necessary configuration files 3. Install Required DLCs • Purchase and install: Royalty, Ideology, Biotech, Anomaly • Each DLC must be downloaded and activated 4. Verify Installation • Launch RimWorld again • Check that all DLCs appear in the main menu • Ensure no error messages appear Once completed, return to this screen and click "Yes Mother".""" # Create a temporary label to measure text size temp_label = tk.Label(instruction_window, text=instructions_text, font=self.get_font(11), justify='left', wraplength=550) temp_label.update_idletasks() # Get the required text dimensions text_width = temp_label.winfo_reqwidth() text_height = temp_label.winfo_reqheight() # Destroy temporary label temp_label.destroy() # Calculate window size (25% larger than text + padding for title and button) window_width = int(text_width * 1.25) + 60 # 25% larger + padding window_height = int(text_height * 1.25) + 140 # 25% larger + space for title and button # Set the calculated window size instruction_window.geometry(f"{window_width}x{window_height}") # Center the window on screen instruction_window.update_idletasks() x = (instruction_window.winfo_screenwidth() // 2) - (window_width // 2) y = (instruction_window.winfo_screenheight() // 2) - (window_height // 2) instruction_window.geometry(f"{window_width}x{window_height}+{x}+{y}") # Title title_label = tk.Label(instruction_window, text="Progression: Loader - Setup Instructions", fg='white', bg='#2b2b2b', font=self.get_font(16, 'bold')) title_label.pack(pady=20) # Instructions label with proper sizing instructions_label = tk.Label(instruction_window, text=instructions_text, fg='#cccccc', bg='#2b2b2b', font=self.get_font(11), justify='left', wraplength=text_width) # Use measured width instructions_label.pack(pady=20, padx=30, expand=True) # Close button close_btn = tk.Button(instruction_window, text="I Understand", command=instruction_window.destroy, bg='#0078d4', fg='white', font=self.get_font(12, 'bold'), padx=30, pady=10) close_btn.pack(pady=20) def stage_5_check_expansions(self): """Stage 5: Check for owned expansions (renamed from stage_3)""" # Show expansion check frame self.expansion_frame.pack(fill=tk.BOTH, expand=True) # Add title check_title = tk.Label(self.expansion_frame, text="Checking RimWorld Expansions...", fg='white', bg='#2b2b2b', font=self.get_font(18, 'bold')) check_title.pack(pady=20) # Check expansions in a separate thread threading.Thread(target=self.check_expansions_thread, daemon=True).start() def stage_6_final_confirmation(self): """Stage 6: Final confirmation screen (renamed from stage_5)""" # Hide subscription frame, show final frame self.subscription_frame.pack_forget() self.final_frame.pack(fill=tk.BOTH, expand=True) # Final message final_title = tk.Label(self.final_frame, text="Ready to Create Mod List", fg='white', bg='#2b2b2b', font=self.get_font(18, 'bold')) final_title.pack(pady=30) final_message = tk.Label(self.final_frame, text="This tool creates a Mods list in your RimWorld folder\nwith the latest from the Steam Workshop collections for Progression.\n\nWould you like to continue?", fg='#cccccc', bg='#2b2b2b', font=self.get_font(14), justify='center') final_message.pack(pady=20) # Buttons frame buttons_frame = tk.Frame(self.final_frame, bg='#2b2b2b') buttons_frame.pack(pady=30) # Continue button continue_btn = tk.Button(buttons_frame, text="Yes, Continue", command=self.complete_loading, bg='#00aa00', fg='white', font=self.get_font(12, 'bold'), padx=30, pady=10) continue_btn.pack(side=tk.LEFT, padx=10) # Exit button exit_btn = tk.Button(buttons_frame, text="No, Exit", command=self.exit_application, bg='#aa0000', fg='white', font=self.get_font(12, 'bold'), padx=30, pady=10) exit_btn.pack(side=tk.LEFT, padx=10) def open_hr_website(self, event=None): """Open Hudson Riggs Systems website""" import webbrowser webbrowser.open('https://hudsonriggs.systems') def on_loading_hr_enter(self, event=None): """Handle mouse enter on HR logo in loading screen""" if hasattr(self, 'hr_logo_glow_tk'): self.hr_logo_label.configure(image=self.hr_logo_glow_tk) else: self.hr_logo_label.configure(fg='#ff8686', bg='#404040') def on_loading_hr_leave(self, event=None): """Handle mouse leave on HR logo in loading screen""" if hasattr(self, 'hr_logo_tk'): self.hr_logo_label.configure(image=self.hr_logo_tk) else: self.hr_logo_label.configure(fg='#888888', bg='#2b2b2b') def open_collection_url(self, url): """Open Steam Workshop collection URL in browser""" import webbrowser # Clean up steam:// protocol URLs if url.startswith('steam://openurl/'): url = url.replace('steam://openurl/', '') webbrowser.open(url) def on_link_enter(self, label): """Handle mouse enter on collection link (hover effect)""" label.configure(fg='#66b3ff') # Lighter blue on hover def on_link_leave(self, label): """Handle mouse leave on collection link (remove hover effect)""" label.configure(fg='#4da6ff') # Return to normal blue def complete_loading(self): """Complete the loading sequence and show main app""" self.loading_window.destroy() self.on_complete_callback() def exit_application(self): """Exit the application""" self.root.quit() def on_close(self): """Handle window close event""" self.exit_application() class SteamWorkshopGUI: def __init__(self, root): self.root = root self.root.title("Progression: Loader") self.root.state('zoomed') # Make fullscreen on Windows 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 # Initialize blinking state and button references self.blink_state = False self.rimworld_label = None self.load_btn = None self.merge_btn = None self.is_rimworld_valid = False # Create main frame main_frame = tk.Frame(root, bg='#2b2b2b') main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Create header frame for progression logo self.create_header_frame(main_frame) # Create content frame (below header) content_frame = tk.Frame(main_frame, bg='#2b2b2b') content_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0)) # Left panel for inputs left_frame = tk.Frame(content_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 - reduced width to accommodate HR logo right_frame = tk.Frame(content_frame, bg='#2b2b2b') right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(0, 120)) # Added right padding for logo space # Create footer frame for HR Systems logo self.create_footer_frame(main_frame) self.create_input_section(left_frame) self.create_output_section(right_frame) # Log initial message self.log_to_output("🔍 Please provide a valid RimWorld installation path to continue.\n") self.log_to_output(" The 'RimWorld Game Folder' label will stop blinking once a valid path is detected.\n\n") # Initialize update checker (for manual checks only) config = get_update_config() self.update_checker = UpdateChecker(config["current_version"]) # Add update check functionality self.add_update_check_button() # Check initial state now that all GUI elements are created self.on_rimworld_path_change() # Start blinking animation for RimWorld label (with slight delay to ensure GUI is ready) self.root.after(1000, self.start_rimworld_label_blink) def create_header_frame(self, parent): """Create header frame with progression logo""" header_frame = tk.Frame(parent, bg='#2b2b2b', height=100) header_frame.pack(fill='x', pady=(0, 10)) header_frame.pack_propagate(False) # Create custom frame with border for progression logo logo_container = tk.Frame(header_frame, bg='#404040', relief='raised', bd=2) logo_container.pack(expand=True) # Load and display progression logo (using game title as progression logo) try: # Load the game title image as progression logo progression_img = Image.open(get_resource_path("art/GameTitle.png")) # Resize to fit header img_width, img_height = progression_img.size new_height = 80 new_width = int((new_height / img_height) * img_width) progression_img = progression_img.resize((new_width, new_height), Image.Resampling.LANCZOS) self.progression_logo_tk = ImageTk.PhotoImage(progression_img) progression_label = tk.Label(logo_container, image=self.progression_logo_tk, bg='#404040') progression_label.pack(padx=10, pady=10) except Exception as e: # Fallback text if image loading fails progression_label = tk.Label(logo_container, text="PROGRESSION PACK", fg='white', bg='#404040', font=self.get_font(20, 'bold')) progression_label.pack(padx=20, pady=30) def create_footer_frame(self, parent): """Create footer frame with HR Systems logo in bottom right""" self.footer_frame = tk.Frame(parent, bg='#2b2b2b', height=100) # Increased height for better visibility self.footer_frame.pack(fill='x', side=tk.BOTTOM, pady=(10, 0)) self.footer_frame.pack_propagate(False) # Create clickable HR Systems logo in bottom right self.create_hr_logo(self.footer_frame) def create_hr_logo(self, parent): """Create clickable HR Systems logo with radial glow hover effects""" # Container for logo positioned in bottom right with extra space for glow logo_frame = tk.Frame(parent, bg='#2b2b2b') logo_frame.pack(side=tk.RIGHT, anchor='se', padx=40, pady=20) # Increased padding for better visibility try: # Load HR Systems logo hr_img = Image.open(get_resource_path("art/hudsonriggssystems.png")) # Resize to appropriate size for footer - slightly larger for better visibility img_width, img_height = hr_img.size new_height = 60 # Increased from 50 to 60 new_width = int((new_height / img_height) * img_width) hr_img = hr_img.resize((new_width, new_height), Image.Resampling.LANCZOS) self.hr_logo_normal = ImageTk.PhotoImage(hr_img) # Create radial glow effect version self.hr_logo_glow = self.create_radial_glow_image(hr_img) # Create clickable label with fixed size to prevent position changes # Calculate the glow image size to set fixed dimensions glow_padding = 20 label_width = new_width + (glow_padding * 2) label_height = new_height + (glow_padding * 2) self.hr_logo_label = tk.Label(logo_frame, image=self.hr_logo_normal, bg='#2b2b2b', cursor='hand2', width=label_width, height=label_height, compound='center') # Center the image within the fixed size self.hr_logo_label.pack() # Bind click and hover events self.hr_logo_label.bind('', self.open_hr_website) self.hr_logo_label.bind('', self.on_hr_logo_enter) self.hr_logo_label.bind('', self.on_hr_logo_leave) except Exception as e: # Fallback text link if image loading fails self.hr_logo_label = tk.Label(logo_frame, text="Hudson Riggs Systems", fg='#888888', bg='#2b2b2b', font=self.get_font(10), cursor='hand2') self.hr_logo_label.pack() # Bind events for text version self.hr_logo_label.bind('', self.open_hr_website) self.hr_logo_label.bind('', self.on_hr_text_enter) self.hr_logo_label.bind('', self.on_hr_text_leave) def create_radial_glow_image(self, base_image): """Create a radial glow effect around the image with #ff8686 color""" # Get base image dimensions base_width, base_height = base_image.size # Create larger canvas for glow effect glow_padding = 20 glow_width = base_width + (glow_padding * 2) glow_height = base_height + (glow_padding * 2) # Create glow background matching the label background glow_bg = Image.new('RGBA', (glow_width, glow_height), (43, 43, 43, 255)) # #2b2b2b background # Create radial glow mask glow_mask = Image.new('L', (glow_width, glow_height), 0) draw = ImageDraw.Draw(glow_mask) # Calculate center and create multiple circles for smooth gradient center_x, center_y = glow_width // 2, glow_height // 2 # Create glow that extends beyond the original image max_radius = glow_padding + max(base_width, base_height) // 2 # Create radial gradient by drawing concentric circles for i in range(max_radius, 0, -1): # Calculate alpha based on distance from center (stronger in center) # Make the glow more intense closer to the logo if i <= max(base_width, base_height) // 2: # Inner area (around the logo) - stronger glow alpha = int(255 * 0.8) else: # Outer area - fade out fade_factor = (max_radius - i) / (max_radius - max(base_width, base_height) // 2) alpha = int(255 * fade_factor * 0.6) if alpha > 0: draw.ellipse([center_x - i, center_y - i, center_x + i, center_y + i], fill=alpha) # Apply blur for smooth glow effect glow_mask = glow_mask.filter(ImageFilter.GaussianBlur(radius=8)) # Create glow color layer (#ff8686) glow_color = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 0)) glow_layer = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 255)) # Apply the mask to create the glow effect glow_color.paste(glow_layer, mask=glow_mask) # Composite glow onto background glow_bg = Image.alpha_composite(glow_bg, glow_color) # Convert base image to RGBA if needed if base_image.mode != 'RGBA': base_image = base_image.convert('RGBA') # Paste the original image in the exact center to maintain position paste_x = (glow_width - base_width) // 2 paste_y = (glow_height - base_height) // 2 glow_bg.paste(base_image, (paste_x, paste_y), base_image) return ImageTk.PhotoImage(glow_bg) def open_hr_website(self, event=None): """Open Hudson Riggs Systems website""" import webbrowser webbrowser.open('https://hudsonriggs.systems') def on_hr_logo_enter(self, event=None): """Handle mouse enter on HR logo (radial glow effect)""" if hasattr(self, 'hr_logo_glow'): self.hr_logo_label.configure(image=self.hr_logo_glow) def on_hr_logo_leave(self, event=None): """Handle mouse leave on HR logo (remove glow effect)""" if hasattr(self, 'hr_logo_normal'): self.hr_logo_label.configure(image=self.hr_logo_normal) def on_hr_text_enter(self, event=None): """Handle mouse enter on HR text (glow effect)""" self.hr_logo_label.configure(fg='#ff8686', bg='#404040') def on_hr_text_leave(self, event=None): """Handle mouse leave on HR text (remove glow effect)""" self.hr_logo_label.configure(fg='#888888', bg='#2b2b2b') def load_custom_font(self): """Load the RimWorld font using Windows AddFontResourceEx with private flag""" try: font_path = get_resource_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="Progression: Loader", 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)) self.rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:", font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#00bfff') # Start with bright light blue self.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) # Don't check initial state here - will be done after all GUI elements are created 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) # White line separator above ModsConfig section separator_line = tk.Frame(parent, bg='#ffffff', height=1) separator_line.pack(fill='x', pady=(15, 5)) # Warning text above ModsConfig section warning_label = tk.Label(parent, text="Don't edit anything below unless you know what you are doing", font=self.get_font(8), bg='#2b2b2b', fg='#ffaa00', # Orange warning color anchor='w') warning_label.pack(anchor='w', pady=(0, 10)) # 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 self.load_btn = tk.Button(parent, text="Load Progression Pack Complete", command=self.load_progression_pack, bg='#404040', fg='#888888', font=self.get_font(12, 'bold'), # Greyed out initially relief='flat', padx=30, pady=12, state='disabled') self.load_btn.pack(pady=30) # Merge with Current Mods button (yellow) self.merge_btn = tk.Button(parent, text="Merge with Current Mods Config", command=self.merge_with_current_mods, bg='#404040', fg='#888888', font=self.get_font(12, 'bold'), # Greyed out initially relief='flat', padx=30, pady=12, state='disabled') self.merge_btn.pack(pady=(10, 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="Logs", 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 log_to_output(self, message): """Log a message to the output text area""" if hasattr(self, 'output_text'): self.output_text.insert(tk.END, message) self.output_text.see(tk.END) # Scroll to bottom 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: # Check if this is a valid RimWorld installation is_valid = self.validate_rimworld_path(rimworld_path) if is_valid: # Stop blinking and set to normal color self.is_rimworld_valid = True self.rimworld_label.configure(fg='#ffffff') # White for valid path self.enable_buttons() # 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) # Log success self.log_to_output(f"✓ Valid RimWorld installation detected at: {rimworld_path}\n") self.log_to_output(f"✓ Workshop folder derived: {workshop_path}\n") except (StopIteration, IndexError): self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") self.log_to_output("⚠ Warning: RimWorld path should contain 'steamapps'\n") else: self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") self.log_to_output("⚠ Warning: RimWorld path should contain 'steamapps'\n") else: # Invalid RimWorld path - keep blinking and buttons disabled self.is_rimworld_valid = False self.disable_buttons() self.workshop_var.set("Invalid RimWorld path") self.log_to_output(f"✗ Invalid RimWorld installation at: {rimworld_path}\n") self.log_to_output(" Please ensure the path contains RimWorld.exe (or RimWorldWin64.exe) and Data folder\n") else: # Empty path - keep blinking and buttons disabled self.is_rimworld_valid = False self.disable_buttons() self.workshop_var.set("Enter RimWorld path above") def validate_rimworld_path(self, path): """Validate if the given path is a valid RimWorld installation""" if not path or not os.path.exists(path): return False # Check for RimWorld executable (can be RimWorld.exe or RimWorldWin64.exe) rimworld_exe = os.path.join(path, "RimWorld.exe") rimworld_win64_exe = os.path.join(path, "RimWorldWin64.exe") if not (os.path.exists(rimworld_exe) or os.path.exists(rimworld_win64_exe)): return False # Check for Data folder (contains core game data) data_folder = os.path.join(path, "Data") if not os.path.exists(data_folder): return False # Check for Core mod in Data folder core_folder = os.path.join(data_folder, "Core") if not os.path.exists(core_folder): return False return True def start_rimworld_label_blink(self): """Start the blinking animation for RimWorld label""" if self.rimworld_label: if not self.is_rimworld_valid: # Toggle between bright light blue and dark grey for maximum contrast if self.blink_state: self.rimworld_label.configure(fg='#00bfff') # Bright light blue (DeepSkyBlue) else: self.rimworld_label.configure(fg='#555555') # Even darker grey for more contrast self.blink_state = not self.blink_state # Continue blinking until valid path is provided (even faster blink rate) self.root.after(500, self.start_rimworld_label_blink) # Even faster blink (500ms) def enable_buttons(self): """Enable the buttons when RimWorld path is valid""" if self.load_btn: self.load_btn.configure(state='normal', bg='#0078d4', fg='white') if self.merge_btn: self.merge_btn.configure(state='normal', bg='#ffcc00', fg='black') def disable_buttons(self): """Disable the buttons when RimWorld path is invalid""" if self.load_btn: self.load_btn.configure(state='disabled', bg='#404040', fg='#888888') if self.merge_btn: self.merge_btn.configure(state='disabled', bg='#404040', fg='#888888') 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.is_rimworld_valid: self.log_to_output("✗ Please provide a valid RimWorld installation path first!\n") return 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 merge_with_current_mods(self): """Merge current active mods with Steam collection mods""" if not self.is_rimworld_valid: self.log_to_output("✗ Please provide a valid RimWorld installation path first!\n") return # Show warning dialog warning_message = ("⚠️ WARNING ⚠️\n\n" "This will merge your current active mods with the Steam Workshop collections.\n" "This may create a potentially unwanted experience with mod conflicts,\n" "incompatibilities, or performance issues.\n\n" "The merged mod list will be saved as 'ProgressionHomebrew'.\n\n" "Do you want to continue?") result = messagebox.askyesno("Merge Warning", warning_message, icon='warning') if result: 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 if not self.modsconfig_path or not os.path.exists(self.modsconfig_path): self.output_text.delete(1.0, tk.END) self.output_text.insert(tk.END, "ModsConfig.xml not found!\nPlease ensure RimWorld has been launched at least once.") return self.output_text.delete(1.0, tk.END) self.output_text.insert(tk.END, "Merging current mods with Progression Pack...\n\n") threading.Thread(target=self._merge_with_current_mods_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 _merge_with_current_mods_thread(self): """Thread function to merge current mods with progression pack""" try: # Step 1: Get current active mods from ModsConfig.xml self._safe_update_output("Reading current active mods from ModsConfig.xml...\n") current_mods = self.get_current_active_mods() self._safe_update_output(f"Found {len(current_mods)} currently active mods\n\n") # Step 2: Get Steam Workshop collection mods 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_workshop_ids = list(set(all_workshop_ids)) self._safe_update_output(f"\nTotal unique workshop items: {len(unique_workshop_ids)}\n") self._safe_update_output("Extracting package names from About.xml files...\n\n") # Extract package names from About.xml files for workshop mods workshop_results = [] for workshop_id in sorted(unique_workshop_ids): package_name = self.get_package_name_from_about_xml(workshop_id) workshop_results.append((workshop_id, package_name)) # Step 3: Remove duplicates and merge the mod lists self._safe_update_output("=== MERGING CURRENT MODS WITH PROGRESSION PACK ===\n\n") # Display current mods self._safe_update_output("Current Active Mods:\n") self._safe_update_output("-" * 50 + "\n") for package_id, mod_name in current_mods: self._safe_update_output(f"{package_id} | {mod_name}\n") self._safe_update_output(f"\nWorkshop Mods from Collections:\n") self._safe_update_output("-" * 50 + "\n") for workshop_id, package_name in workshop_results: self._safe_update_output(f"{workshop_id:<15} | {package_name}\n") # Step 4: Remove duplicates based on package ID and workshop ID current_package_ids = {package_id for package_id, _ in current_mods} current_workshop_ids = {package_id for package_id, _ in current_mods if package_id.isdigit()} filtered_workshop_results = [] duplicates_found = [] for workshop_id, package_id in workshop_results: # Check for duplicate package ID if package_id in current_package_ids: duplicates_found.append((workshop_id, package_id, "Package ID already exists")) # Check for duplicate workshop ID (user might have same mod in current list) elif workshop_id in current_workshop_ids: duplicates_found.append((workshop_id, package_id, "Workshop ID already exists")) else: filtered_workshop_results.append((workshop_id, package_id)) # Report duplicates if duplicates_found: self._safe_update_output(f"\nDuplicates Removed:\n") self._safe_update_output("-" * 70 + "\n") for workshop_id, package_id, reason in duplicates_found: self._safe_update_output(f"{workshop_id:<15} | {package_id:<30} | {reason}\n") else: self._safe_update_output(f"\nNo duplicates found - all workshop mods are unique!\n") # Step 5: Create final merged mod list merged_results = current_mods + filtered_workshop_results self._safe_update_output(f"\nFinal Results:\n") self._safe_update_output(f"Current mods: {len(current_mods)}\n") self._safe_update_output(f"New workshop mods: {len(filtered_workshop_results)}\n") self._safe_update_output(f"Duplicates removed: {len(duplicates_found)}\n") self._safe_update_output(f"Total merged mods: {len(merged_results)}\n") # Create ProgressionHomebrew.rml file self.create_homebrew_rml_file(current_mods, filtered_workshop_results) except Exception as e: self._safe_update_output(f"Error merging mods: {str(e)}\n") def get_current_active_mods(self): """Get currently active mods from ModsConfig.xml""" current_mods = [] try: if not self.modsconfig_path or not os.path.exists(self.modsconfig_path): self._safe_update_output("ModsConfig.xml not found\n") return current_mods # Parse ModsConfig.xml tree = ET.parse(self.modsconfig_path) root = tree.getroot() # Get active mods from activeMods section active_mods_element = root.find('activeMods') if active_mods_element is not None: for li in active_mods_element.findall('li'): mod_id = li.text if mod_id: mod_id = mod_id.strip() # Get mod name - try to find it from various sources mod_name = self.get_mod_display_name(mod_id) current_mods.append((mod_id, mod_name)) return current_mods except Exception as e: self._safe_update_output(f"Error reading current mods: {str(e)}\n") return current_mods def get_mod_display_name(self, mod_id): """Get display name for a mod ID from various sources""" # First check if it's a core mod if mod_id.startswith('ludeon.rimworld'): return self.get_default_mod_name(mod_id) # For workshop mods, try to find the About.xml in workshop folder if self.workshop_folder and os.path.exists(self.workshop_folder): # Check if mod_id is a workshop ID (numeric) if mod_id.isdigit(): mod_name = self.get_mod_name_from_about_xml(mod_id) if mod_name != f"Workshop ID {mod_id}": return mod_name # Fallback: return the mod ID itself return mod_id def create_homebrew_rml_file(self, current_mods, workshop_results): """Create ProgressionHomebrew.rml file with merged mods""" 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, "ProgressionHomebrew.rml") # Create XML content for merged mods xml_content = self.generate_homebrew_rml_xml(current_mods, workshop_results) # Write to file with open(rml_file_path, 'w', encoding='utf-8') as f: f.write(xml_content) self._safe_update_output(f"\nProgressionHomebrew.rml created successfully at:\n{rml_file_path}\n") # Show success animation self.show_success_animation("ProgressionHomebrew") except Exception as e: self._safe_update_output(f"Error creating Homebrew RML file: {str(e)}\n") def generate_homebrew_rml_xml(self, current_mods, workshop_results): """Generate the XML content for the homebrew .rml file""" xml_lines = [ '', '', '\t', '\t\t1.6.4633 rev1261', '\t\t' ] # Add current mods first (maintain load order) for package_id, mod_name in current_mods: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') # Add workshop mod package IDs to meta section for workshop_id, package_id in workshop_results: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') xml_lines.extend([ '\t\t
    ', '\t\t' ]) # Add Steam IDs (0 for non-workshop mods, actual ID for workshop mods) for package_id, mod_name in current_mods: if package_id.isdigit(): xml_lines.append(f'\t\t\t
  • {package_id}
  • ') else: xml_lines.append(f'\t\t\t
  • 0
  • ') # Add workshop IDs to meta section for workshop_id, package_id in workshop_results: xml_lines.append(f'\t\t\t
  • {workshop_id}
  • ') xml_lines.extend([ '\t\t
    ', '\t\t' ]) # Add current mod names first for package_id, mod_name in current_mods: xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') # Add workshop mod names to meta section for workshop_id, package_id in workshop_results: mod_name = self.get_mod_name_from_about_xml(workshop_id) xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') xml_lines.extend([ '\t\t
    ', '\t', '\t', '\t\t' ]) # Add current mods first to modList section for package_id, mod_name in current_mods: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') # Add workshop mod package IDs to modList section for workshop_id, package_id in workshop_results: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') xml_lines.extend([ '\t\t
    ', '\t\t' ]) # Add current mod names first to modList section for package_id, mod_name in current_mods: xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') # Add workshop mod names to modList section for workshop_id, package_id in workshop_results: mod_name = self.get_mod_name_from_about_xml(workshop_id) xml_lines.append(f'\t\t\t
  • {mod_name}
  • ') xml_lines.extend([ '\t\t
    ', '\t
    ', '
    ' ]) return '\n'.join(xml_lines) 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") # Show success animation self.show_success_animation("ProgressionVanilla") 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 msg = "=== GENERATING RML XML ===\n" self._safe_update_output(msg) print(msg.strip()) core_mods = self.get_core_mods_from_config() msg = f"Core mods returned: {core_mods}\n" self._safe_update_output(msg) print(msg.strip()) # 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 msg = "Adding core mods to XML:\n" self._safe_update_output(msg) print(msg.strip()) for package_id, mod_name in core_mods: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') msg = f" Added core mod: {package_id} ({mod_name})\n" self._safe_update_output(msg) print(msg.strip()) # 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 - includes ALL DLC the user owns""" core_mods = [] try: if not self.modsconfig_path or not os.path.exists(self.modsconfig_path): msg = "ModsConfig.xml not found, using default core mods\n" self._safe_update_output(msg) print(msg.strip()) return [('ludeon.rimworld', 'RimWorld')] msg = f"Reading ModsConfig.xml from: {self.modsconfig_path}\n" self._safe_update_output(msg) print(msg.strip()) # 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") msg = f"RimWorld Data path: {rimworld_data_path}\n" self._safe_update_output(msg) print(msg.strip()) else: msg = "No RimWorld path set, using fallback names\n" self._safe_update_output(msg) print(msg.strip()) # Find knownExpansions section and include ALL expansions found known_expansions_element = root.find('knownExpansions') if known_expansions_element is not None: msg = "Found knownExpansions section, processing all DLC...\n" self._safe_update_output(msg) print(msg.strip()) for li in known_expansions_element.findall('li'): expansion_id = li.text if expansion_id: expansion_id = expansion_id.strip() msg = f"Processing DLC: {expansion_id}\n" self._safe_update_output(msg) print(msg.strip()) # Use the same method as DLC detection to get real names mod_name = self.get_expansion_real_name(expansion_id, rimworld_data_path) msg = f"get_expansion_real_name returned: {mod_name}\n" self._safe_update_output(msg) print(msg.strip()) if not mod_name: mod_name = self.get_default_mod_name(expansion_id) msg = f"Using fallback name: {mod_name}\n" self._safe_update_output(msg) print(msg.strip()) core_mods.append((expansion_id, mod_name)) msg = f"Added to core_mods: {expansion_id} -> {mod_name}\n" self._safe_update_output(msg) print(msg.strip()) else: msg = "No knownExpansions section found in ModsConfig.xml\n" self._safe_update_output(msg) print(msg.strip()) if not core_mods: # Fallback if no expansions found - just base game core_mods = [('ludeon.rimworld', 'RimWorld')] msg = "No expansions found, using fallback base game only\n" self._safe_update_output(msg) print(msg.strip()) msg = f"Final core_mods list: {core_mods}\n" self._safe_update_output(msg) print(msg.strip()) msg = f"Found {len(core_mods)} core expansions from knownExpansions in ModsConfig.xml\n" self._safe_update_output(msg) print(msg.strip()) except Exception as e: msg = f"Error reading core mods: {str(e)}\n" self._safe_update_output(msg) print(msg.strip()) core_mods = [('ludeon.rimworld', 'RimWorld')] return core_mods def get_expansion_real_name(self, expansion_id, rimworld_data_path): """Get the real expansion name from RimWorld Data folder About.xml""" try: if not rimworld_data_path or not os.path.exists(rimworld_data_path): # Fallback to extracting from ID if no data path return self.extract_name_from_id(expansion_id) # Look for expansion folder in Data directory # Core expansions are usually in subfolders like Royalty, Ideology, etc. possible_folders = [ expansion_id.replace('ludeon.rimworld.', '').capitalize(), expansion_id.split('.')[-1].capitalize() if '.' in expansion_id else expansion_id, expansion_id.replace('ludeon.rimworld.', ''), expansion_id.replace('ludeon.rimworld', 'Core') ] 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 fallback return self.extract_name_from_id(expansion_id) except Exception as e: return self.extract_name_from_id(expansion_id) def extract_name_from_id(self, expansion_id): """Extract expansion name from ID as fallback""" if 'royalty' in expansion_id.lower(): return 'Royalty' elif 'ideology' in expansion_id.lower(): return 'Ideology' elif 'biotech' in expansion_id.lower(): return 'Biotech' elif 'anomaly' in expansion_id.lower(): return 'Anomaly' elif 'odyssey' in expansion_id.lower(): return 'Odyssey' else: # Extract the last part after the last dot and capitalize parts = expansion_id.split('.') if len(parts) > 1: return parts[-1].capitalize() return expansion_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 show_success_animation(self, mod_list_name): """Show success animation with logo returning to center and instructions""" # Close the main creator window self.root.withdraw() # Hide the main window # Create overlay window for success animation self.success_window = tk.Toplevel() self.success_window.title("Progression: Loader - Success!") self.success_window.configure(bg='#2b2b2b') # Set window icon try: icon_path = get_resource_path(os.path.join("art", "Progression.ico")) if os.path.exists(icon_path): self.success_window.iconbitmap(icon_path) except Exception: pass # Ignore icon loading errors for popup windows self.success_window.attributes('-topmost', True) self.success_window.state('zoomed') # Fullscreen self.success_window.protocol("WM_DELETE_WINDOW", self.close_success_animation) # Create main container self.success_main_frame = tk.Frame(self.success_window, bg='#2b2b2b') self.success_main_frame.pack(fill=tk.BOTH, expand=True) # Load and prepare logo for animation try: # Load the progression logo (GameTitle.png) success_logo_img = Image.open(get_resource_path("art/GameTitle.png")) # Start with smaller size (current header size) start_height = 80 start_width = int((start_height / success_logo_img.size[1]) * success_logo_img.size[0]) # Target size (larger for center display) target_height = 200 target_width = int((target_height / success_logo_img.size[1]) * success_logo_img.size[0]) self.success_logo_img = success_logo_img self.success_logo_label = tk.Label(self.success_main_frame, bg='#2b2b2b') # Start animation from top to center self.animate_success_logo(0, 30, start_width, start_height, target_width, target_height, mod_list_name) except Exception as e: # Fallback if image loading fails self.show_success_text_only(mod_list_name) def animate_success_logo(self, step, total_steps, start_width, start_height, target_width, target_height, mod_list_name): """Animate the logo moving from top to center with size increase""" if step <= total_steps: # Calculate progress with easing progress = step / total_steps eased_progress = 1 - (1 - progress) ** 3 # Ease-out # Get window dimensions window_width = self.success_window.winfo_width() window_height = self.success_window.winfo_height() if window_width <= 1 or window_height <= 1: # Window not ready, try again self.success_window.after(50, lambda: self.animate_success_logo( step, total_steps, start_width, start_height, target_width, target_height, mod_list_name)) return # Calculate positions start_x = window_width // 2 start_y = 60 # Top position (header position) target_x = window_width // 2 target_y = window_height // 2 - 50 # Slightly above center current_x = start_x + (target_x - start_x) * eased_progress current_y = start_y + (target_y - start_y) * eased_progress # Calculate current size current_width = start_width + (target_width - start_width) * eased_progress current_height = start_height + (target_height - start_height) * eased_progress # Resize and display logo resized_img = self.success_logo_img.resize( (int(current_width), int(current_height)), Image.Resampling.LANCZOS ) current_tk_img = ImageTk.PhotoImage(resized_img) self.success_logo_label.configure(image=current_tk_img) self.success_logo_label.image = current_tk_img # Keep reference # Position the label self.success_logo_label.place(x=current_x, y=current_y, anchor='center') # Schedule next step self.success_window.after(50, lambda: self.animate_success_logo( step + 1, total_steps, start_width, start_height, target_width, target_height, mod_list_name )) else: # Animation complete - show success message self.show_success_message(mod_list_name) def show_success_text_only(self, mod_list_name): """Show success message without logo animation (fallback)""" # Create centered text success_title = tk.Label(self.success_main_frame, text="success!", fg='#00ff00', bg='#2b2b2b', font=self.get_font(24, 'bold')) success_title.pack(expand=True) self.show_success_message(mod_list_name) def show_success_message(self, mod_list_name): """Show the success message and instructions""" # Success message success_text = tk.Label(self.success_main_frame, text="mods list created successfully!", fg='#00ff00', bg='#2b2b2b', font=self.get_font(20, 'bold')) success_text.pack(pady=20) # Mod list name name_text = tk.Label(self.success_main_frame, text=f"it is named: {mod_list_name.lower()}", fg='white', bg='#2b2b2b', font=self.get_font(16)) name_text.pack(pady=10) # Instructions (different for homebrew vs vanilla) if mod_list_name == "ProgressionHomebrew": instructions_text = f"""to load it: 1. open your mod manager in game 2. go to load section 3. press load next to "{mod_list_name.lower()}" ⚠️ important for homebrew version ⚠️ this contains your current mods + progression pack you must auto sort after loading to prevent conflicts! some mods may be incompatible - check for errors!""" else: instructions_text = f"""to load it: 1. open your mod manager in game 2. go to load section 3. press load next to "{mod_list_name.lower()}" you must auto sort after this!""" instructions_label = tk.Label(self.success_main_frame, text=instructions_text, fg='#cccccc', bg='#2b2b2b', font=self.get_font(14), justify='center') instructions_label.pack(pady=30) # Auto sort warning (more prominent for homebrew) if mod_list_name == "ProgressionHomebrew": warning_text = tk.Label(self.success_main_frame, text="⚠️ critical: auto sort after loading! check for mod conflicts! ⚠️", fg='#ff4444', bg='#2b2b2b', font=self.get_font(16, 'bold')) else: warning_text = tk.Label(self.success_main_frame, text="⚠️ important: auto sort after loading! ⚠️", fg='#ff8686', bg='#2b2b2b', font=self.get_font(16, 'bold')) warning_text.pack(pady=20) # Close button close_btn = tk.Button(self.success_main_frame, text="got it!", command=self.close_success_animation, bg='#00aa00', fg='white', font=self.get_font(14, 'bold'), padx=40, pady=15) close_btn.pack(pady=30) # No auto-close - user must manually close def close_success_animation(self): """Close the success animation window and exit the application""" if hasattr(self, 'success_window'): self.success_window.destroy() # Exit the entire application since main window is hidden self.root.quit() def add_update_check_button(self): """Add an update check button to the footer""" try: if hasattr(self, 'footer_frame'): update_btn = tk.Button(self.footer_frame, text="Check for Updates", command=self.manual_update_check, bg='#404040', fg='white', font=('Arial', 10), padx=15, pady=5, cursor='hand2') update_btn.pack(side='left', padx=10, pady=10) except Exception as e: print(f"Could not add update button: {e}") def manual_update_check(self): """Manually triggered update check""" self.update_checker.manual_check_for_updates(self.root) def main(): root = tk.Tk() root.withdraw() # Hide main window initially # Import config from update_config import get_update_config # Set window icon try: icon_path = get_resource_path(os.path.join("art", "Progression.ico")) if os.path.exists(icon_path): root.iconbitmap(icon_path) except Exception as e: print(f"Could not load icon: {e}") def show_main_app(): """Show the main application after loading is complete""" root.deiconify() # Show main window app = SteamWorkshopGUI(root) # Check for updates SYNCHRONOUSLY before starting anything config = get_update_config() update_info = check_for_updates_before_startup(root) if update_info and config.get("updates_required", True): # Show blocking update dialog - app won't continue until user updates show_blocking_update_dialog(root, update_info['checker'], update_info['release']) else: # No update needed or updates not required, start loading screen loading_screen = LoadingScreen(root, show_main_app) # If update available but not required, show non-blocking dialog if update_info: root.after(1000, lambda: show_optional_update_dialog(root, update_info['checker'], update_info['release'])) root.mainloop() def check_for_updates_before_startup(root): """Check for updates synchronously before starting the application""" try: from update_checker import UpdateChecker from update_config import get_update_config print("Checking for updates...") config = get_update_config() update_checker = UpdateChecker(config["current_version"]) # Use synchronous check to block until complete release_info, error = update_checker.check_for_updates_sync() if error: print(f"Update check failed: {error}") return None elif release_info: latest_version = release_info['version'] if update_checker.is_newer_version(latest_version): print(f"Update required: {latest_version}") return { 'checker': update_checker, 'release': release_info } else: print(f"Already up to date: {latest_version}") else: print("No release info available") return None except Exception as e: print(f"Update check failed: {e}") return None def show_blocking_update_dialog(root, update_checker, release_info): """Show a blocking update dialog that prevents app from continuing""" # Create a new window for the update dialog update_window = tk.Toplevel(root) update_window.title("Update Required - Progression Loader") update_window.configure(bg='#2b2b2b') update_window.resizable(False, False) update_window.attributes('-topmost', True) # Make it modal - user can't interact with other windows update_window.transient(root) update_window.grab_set() # Set window icon try: icon_path = get_resource_path(os.path.join("art", "Progression.ico")) if os.path.exists(icon_path): update_window.iconbitmap(icon_path) except: pass # Calculate window size and center it window_width = 600 window_height = 600 # Increased height to accommodate buttons # Get screen dimensions screen_width = update_window.winfo_screenwidth() screen_height = update_window.winfo_screenheight() # Calculate center position x = (screen_width - window_width) // 2 y = (screen_height - window_height) // 2 update_window.geometry(f"{window_width}x{window_height}+{x}+{y}") # Title title_label = tk.Label(update_window, text="🔄 Update Required", fg='#ff6b6b', bg='#2b2b2b', font=('Arial', 20, 'bold')) title_label.pack(pady=20) # Message message_label = tk.Label(update_window, text="A new version is available and required to continue.", fg='white', bg='#2b2b2b', font=('Arial', 14)) message_label.pack(pady=10) # Version info version_frame = tk.Frame(update_window, bg='#2b2b2b') version_frame.pack(pady=15) current_label = tk.Label(version_frame, text=f"Current Version: {update_checker.current_version}", fg='#cccccc', bg='#2b2b2b', font=('Arial', 12)) current_label.pack() latest_label = tk.Label(version_frame, text=f"Required Version: {release_info['version']}", fg='#4ecdc4', bg='#2b2b2b', font=('Arial', 12, 'bold')) latest_label.pack(pady=5) # Release notes if release_info.get('body'): notes_label = tk.Label(update_window, text="What's New:", fg='white', bg='#2b2b2b', font=('Arial', 14, 'bold')) notes_label.pack(pady=(20, 10)) # Create scrollable text widget for release notes notes_frame = tk.Frame(update_window, bg='#2b2b2b') notes_frame.pack(fill='both', expand=True, padx=40, pady=(0, 10)) notes_text = tk.Text(notes_frame, height=6, # Reduced height to make room for buttons bg='#404040', fg='#ffffff', font=('Arial', 11), wrap=tk.WORD, state='disabled', relief='flat', bd=0) scrollbar = tk.Scrollbar(notes_frame, bg='#404040') scrollbar.pack(side='right', fill='y') notes_text.pack(side='left', fill='both', expand=True) notes_text.config(yscrollcommand=scrollbar.set) scrollbar.config(command=notes_text.yview) # Insert release notes notes_text.config(state='normal') notes_text.insert('1.0', release_info['body']) notes_text.config(state='disabled') # Buttons - fixed at bottom button_frame = tk.Frame(update_window, bg='#2b2b2b') button_frame.pack(side='bottom', pady=20) # Pack at bottom with padding def auto_update(): """Download and install the update automatically""" try: # Disable buttons during download auto_btn.config(state='disabled', text='Downloading...') download_btn.config(state='disabled') exit_btn.config(state='disabled') # Update the window to show progress update_window.update() # Start download in a separate thread import threading threading.Thread(target=perform_auto_update, daemon=True).start() except Exception as e: print(f"Auto update failed: {e}") # Re-enable buttons on error auto_btn.config(state='normal', text='Auto Update') download_btn.config(state='normal') exit_btn.config(state='normal') def perform_auto_update(): """Perform the actual auto update process""" try: import requests import tempfile import shutil import subprocess import sys # Find the EXE asset in the release exe_asset = None for asset in release_info.get('assets', []): if asset.get('name', '').endswith('.exe'): exe_asset = asset break if not exe_asset: raise Exception("No EXE file found in release assets") download_url = exe_asset.get('browser_download_url') if not download_url: raise Exception("No download URL found for EXE asset") # Update status root.after(0, lambda: auto_btn.config(text='Downloading...')) # Download the new EXE response = requests.get(download_url, stream=True) response.raise_for_status() # Save to temporary file with tempfile.NamedTemporaryFile(delete=False, suffix='.exe') as temp_file: temp_path = temp_file.name for chunk in response.iter_content(chunk_size=8192): temp_file.write(chunk) # Get current executable path if getattr(sys, 'frozen', False): # Running as PyInstaller bundle current_exe = sys.executable else: # Running as script - for testing current_exe = sys.argv[0] # Update status root.after(0, lambda: auto_btn.config(text='Installing...')) # Get configuration from update_config import get_update_config config = get_update_config() auto_restart = config.get("auto_restart_after_update", True) # Create a batch script to replace the executable and optionally restart if auto_restart: restart_command = f'start "" /B "{current_exe}"' restart_message = "Starting new version..." else: restart_command = 'echo Please manually start the application.' restart_message = "Please manually start the updated application." batch_script = f'''@echo off echo Updating Progression Loader... timeout /t 3 /nobreak >nul REM Replace the executable move "{temp_path}" "{current_exe}" if errorlevel 1 ( echo Failed to replace executable pause exit /b 1 ) echo Update complete! {restart_message} timeout /t 1 /nobreak >nul REM Start the new executable (if configured) {restart_command} REM Clean up this batch file timeout /t 2 /nobreak >nul del "%~f0" ''' # Write batch script to temp file with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.bat') as batch_file: batch_file.write(batch_script) batch_path = batch_file.name # Update status root.after(0, lambda: auto_btn.config(text='Restarting...')) # Show success message root.after(0, lambda: tk.messagebox.showinfo( "Update Complete", "Update downloaded successfully!\nThe application will restart automatically.", parent=update_window )) # Execute the batch script and exit subprocess.Popen([batch_path], shell=True) # Exit the current application root.after(2000, root.quit) except Exception as e: print(f"Auto update failed: {e}") # Re-enable buttons on error root.after(0, lambda: auto_btn.config(state='normal', text='Auto Update')) root.after(0, lambda: download_btn.config(state='normal')) root.after(0, lambda: exit_btn.config(state='normal')) # Show error message root.after(0, lambda: tk.messagebox.showerror( "Auto Update Failed", f"Failed to automatically update:\n{str(e)}\n\nPlease use 'Download Page' to update manually.", parent=update_window )) def download_page(): """Open the release download page""" webbrowser.open(release_info['html_url']) print("Please download and install the update manually, then restart the application.") root.quit() # Exit the entire application def exit_app(): """Exit the application without updating""" print("Application cannot continue without updating.") root.quit() # Auto Update button (primary action) auto_btn = tk.Button(button_frame, text="Auto Update", command=auto_update, bg='#4ecdc4', fg='white', font=('Arial', 14, 'bold'), padx=30, pady=10, cursor='hand2') auto_btn.pack(side='left', padx=10) # Download Page button (secondary action) download_btn = tk.Button(button_frame, text="Download Page", command=download_page, bg='#45b7d1', fg='white', font=('Arial', 12), padx=20, pady=10, cursor='hand2') download_btn.pack(side='left', padx=10) # Exit button (tertiary action) exit_btn = tk.Button(button_frame, text="Exit Application", command=exit_app, bg='#666666', fg='white', font=('Arial', 12), padx=20, pady=10, cursor='hand2') exit_btn.pack(side='left', padx=10) # Handle window close (same as exit) update_window.protocol("WM_DELETE_WINDOW", exit_app) # Focus on the auto update button auto_btn.focus_set() print("Blocking update dialog shown - app will not continue until user updates") def show_optional_update_dialog(root, update_checker, release_info): """Show a non-blocking update dialog""" try: update_checker.show_update_dialog(root, release_info) except Exception as e: print(f"Could not show update dialog: {e}") if __name__ == "__main__": main()