art whoops! and also font whoops! and also failed at fonts and removing demo files.

This commit is contained in:
2026-01-23 22:00:11 -05:00
parent 7f293558b4
commit e4c0a0c05a
15 changed files with 647 additions and 284 deletions

7
.env Normal file
View File

@@ -0,0 +1,7 @@
# Default Collection URLs
CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585
CONTENT_COLLECTION_URL=https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712
COSMETICS_COLLECTION_URL=https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646
# ModsConfig.xml Path Template
MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Default Collection URLs
CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585
CONTENT_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712
COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646
# ModsConfig.xml Path Template
MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml

View File

@@ -0,0 +1,70 @@
name: Build and Upload Release (Windows EXE)
on:
release:
types: [published]
workflow_dispatch: {}
jobs:
build-windows-exe:
name: Build Windows EXE
runs-on: windows
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
shell: powershell
run: |
python -m pip install --upgrade pip
if (Test-Path requirements.txt) { pip install -r requirements.txt }
pip install pyinstaller
- name: Build EXE with PyInstaller
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
# Stamp version into sealoader_version.py from release tag
if ($env:GITHUB_EVENT_NAME -eq 'release') {
$tag = '${{ github.event.release.tag_name }}'
} else {
$tag = (git describe --tags --always) 2>$null
if (-not $tag) { $tag = "0.0.0-dev" }
}
("__version__ = '" + $tag + "'") | Out-File -FilePath sealoader_version.py -Encoding UTF8 -Force
# Bundle PNG resources referenced at runtime
pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader `
--add-data "SeaLoader.png;." `
--add-data "hrsys.png;." `
--icon SeaLoader.ico
- name: Prepare artifact
shell: powershell
run: |
New-Item -ItemType Directory -Force -Path dist_upload | Out-Null
Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe
if (Test-Path README.md) { Copy-Item README.md dist_upload\ }
if (Test-Path LICENSE) { Copy-Item LICENSE dist_upload\ }
Compress-Archive -Path dist_upload\* -DestinationPath SeaLoader_Windows_x64.zip -Force
- name: Upload asset to Release
if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
shell: powershell
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
RELEASE_ID: ${{ github.event.release.id }}
SERVER_URL: ${{ github.server_url }}
run: |
$ErrorActionPreference = 'Stop'
$uploadUrl = "$env:SERVER_URL/api/v1/repos/$env:REPO/releases/$env:RELEASE_ID/assets?name=SeaLoader_Windows_x64.zip"
Write-Host "Uploading asset to $uploadUrl"
Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{ Authorization = "token $env:TOKEN" } -ContentType "application/zip" -InFile "SeaLoader_Windows_x64.zip"
# CI artifact upload removed for GHES compatibility

View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ModMetaData>
<name>RT Fuse</name>
<author>Ratys</author>
<packageId>ratys.rtfuse</packageId>
<url>https://ludeon.com/forums/index.php?topic=11272</url>
<supportedVersions>
<li>1.0</li>
<li>1.1</li>
<li>1.2</li>
<li>1.3</li>
<li>1.4</li>
<li>1.5</li>
<li>1.6</li>
</supportedVersions>
<modDependencies>
<li>
<packageId>brrainz.harmony</packageId>
<displayName>Harmony</displayName>
<steamWorkshopUrl>steam://url/CommunityFilePage/2009463077</steamWorkshopUrl>
<downloadUrl>https://github.com/pardeike/HarmonyRimWorld/releases/latest</downloadUrl>
</li>
</modDependencies>
<description>Researchable (RT Mods research tab) electric fuses to mitigate short circuits. When placed anywhere on a power network, each fuse will safely discharge up to three of network's batteries, mitigating or preventing the explosion.
Does not require a new colony to add or remove (might throw a one-time error).
Refer to forum thread for additional information.</description>
</ModMetaData>

View File

@@ -1,14 +1,15 @@
# Steam Workshop Collection Manager # Steam Workshop Collection Manager
A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod collections. A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod collections with configurable settings.
## Features ## Features
- Dark themed Windows-style GUI - Dark themed Windows-style GUI
- Three input fields for different mod collection types (Core, Content, Cosmetics) - Configurable collection URLs via .env file
- Automatic extraction of Workshop IDs from Steam collection URLs - Automatic workshop path derivation from RimWorld game path
- Real-time output display with scrollable text area - ModsConfig.xml integration for active mod tracking
- Pre-populated with example RimWorld mod collections - Real-time output display with scrollable text areas
- Local mod folder listing and comparison
## Installation ## Installation
@@ -18,6 +19,20 @@ A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod
pip install -r requirements.txt pip install -r requirements.txt
``` ```
## Configuration
The application uses a `.env` file for configuration. You can modify the default settings:
```env
# Default Collection URLs
CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585
CONTENT_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712
COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646
# ModsConfig.xml Path Template
MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml
```
## Usage ## Usage
1. Run the application: 1. Run the application:
@@ -25,16 +40,31 @@ A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod
python steam_workshop_gui.py python steam_workshop_gui.py
``` ```
2. The application comes pre-populated with example collection URLs: 2. **Set RimWorld Path**:
- **Core**: https://steamcommunity.com/workshop/filedetails/?id=3521297585 - Right-click RimWorld in Steam → Manage → Browse local files
- **Content**: steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712 - Copy and paste that path into the "RimWorld Game Folder" field
- **Cosmetics**: steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646 - The workshop path will automatically derive to `steamapps\workshop\content\294100`
3. You can modify these URLs or enter your own Steam Workshop collection URLs 3. **ModsConfig.xml**:
- The path is automatically detected
- You can manually edit if needed
- Click "Load Active Mods" to see currently enabled mods
4. Click "Extract Workshop IDs" to process the collections 4. **Collection Analysis**:
- Modify collection URLs or use defaults from .env
- Click "Extract Workshop IDs" to process collections
- See which mods are [INSTALLED] or [MISSING]
5. The right panel will display all the Workshop IDs found in each collection 5. **Local Mod Management**:
- Click "List Local Mod Folders" to see downloaded workshop mods
- Compare with collection requirements and active mods
## File Structure
- `steam_workshop_gui.py` - Main application
- `.env` - Configuration file
- `requirements.txt` - Python dependencies
- `README.md` - This file
## URL Formats Supported ## URL Formats Supported
@@ -43,6 +73,8 @@ A Python GUI application for extracting Steam Workshop mod IDs from RimWorld mod
## Notes ## Notes
- The application fetches collection data directly from Steam Workshop - All paths are editable in the GUI
- Configuration URLs are loaded from .env file on startup
- Workshop path auto-derives from RimWorld path (steamapps\common\RimWorld → steamapps\workshop\content\294100)
- Processing may take a few seconds depending on collection size - Processing may take a few seconds depending on collection size
- Workshop IDs are extracted from the HTML content of collection pages - Workshop IDs are extracted from HTML content of collection pages

BIN
art/GameTitle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
art/GameTitleOld.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
art/NotOwned_Anomaly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
art/NotOwned_Biotech.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
art/NotOwned_Ideology.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
art/NotOwned_Royalty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
art/RimWordFont4.ttf Normal file

Binary file not shown.

BIN
art/hudsonriggssystems.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -1 +1,3 @@
requests>=2.25.1 requests>=2.25.1
python-dotenv>=0.19.0
pyglet>=1.5.0

View File

@@ -1,12 +1,24 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk, scrolledtext, filedialog, messagebox from tkinter import ttk, scrolledtext, filedialog, messagebox, font
import requests import requests
import re import re
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import threading import threading
import os import os
import winreg
from pathlib import Path from pathlib import Path
import xml.etree.ElementTree as ET
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Try to import pyglet for font loading
try:
import pyglet
PYGLET_AVAILABLE = True
except ImportError:
PYGLET_AVAILABLE = False
print("pyglet not available - custom fonts may not work properly")
class SteamWorkshopGUI: class SteamWorkshopGUI:
def __init__(self, root): def __init__(self, root):
@@ -15,11 +27,15 @@ class SteamWorkshopGUI:
self.root.geometry("1200x800") self.root.geometry("1200x800")
self.root.configure(bg='#2b2b2b') self.root.configure(bg='#2b2b2b')
# Load custom font
self.load_custom_font()
# Configure dark theme # Configure dark theme
self.setup_dark_theme() self.setup_dark_theme()
# Initialize workshop folder # Initialize paths
self.workshop_folder = None self.workshop_folder = None
self.modsconfig_path = None
# Create main frame # Create main frame
main_frame = tk.Frame(root, bg='#2b2b2b') main_frame = tk.Frame(root, bg='#2b2b2b')
@@ -37,6 +53,110 @@ class SteamWorkshopGUI:
self.create_input_section(left_frame) self.create_input_section(left_frame)
self.create_output_section(right_frame) self.create_output_section(right_frame)
def load_custom_font(self):
"""Load the RimWorld font using pyglet"""
try:
font_path = os.path.join("art", "RimWordFont4.ttf")
if os.path.exists(font_path):
abs_font_path = os.path.abspath(font_path)
print(f"Font file found: {abs_font_path}")
if PYGLET_AVAILABLE:
try:
# Use pyglet to add the font
pyglet.font.add_file(abs_font_path)
# Try to get the font family name from pyglet
# Load the font to get its actual family name
pyglet_font = pyglet.font.load(None, 12, bold=False, italic=False)
# Common RimWorld font family names to try
possible_names = [
"RimWorld",
"RimWorldFont",
"RimWordFont4",
"Calibri",
"Arial"
]
# Test each possible font name
for font_name in possible_names:
try:
test_font = font.Font(family=font_name, size=10)
# If we get here, the font worked
self.custom_font_family = font_name
self.custom_font_available = True
print(f"Successfully loaded font with pyglet: {font_name}")
return
except Exception as e:
continue
print("pyglet loaded font but couldn't find working family name")
self.custom_font_available = False
except Exception as e:
print(f"pyglet font loading failed: {e}")
self.custom_font_available = False
else:
print("pyglet not available, trying Windows registration...")
# Fall back to Windows registration method
try:
import ctypes
from ctypes import wintypes
# Add font resource to Windows
gdi32 = ctypes.windll.gdi32
result = gdi32.AddFontResourceW(abs_font_path)
if result > 0:
print(f"Successfully registered font with Windows")
# Notify Windows that fonts have changed
try:
HWND_BROADCAST = 0xFFFF
WM_FONTCHANGE = 0x001D
ctypes.windll.user32.SendMessageW(HWND_BROADCAST, WM_FONTCHANGE, 0, 0)
print("Sent font change notification to Windows")
except Exception as notify_error:
print(f"Could not send font change notification: {notify_error}")
# For Windows registration, let's try using the actual font file name
# Sometimes the family name is different from what we expect
self.custom_font_family = "RimWorld" # Default assumption
self.custom_font_available = True
print(f"Using Windows-registered font: {self.custom_font_family}")
else:
print("Failed to register font with Windows")
self.custom_font_available = False
except Exception as e:
print(f"Windows font registration error: {e}")
self.custom_font_available = False
else:
print(f"Font file not found: {font_path}")
self.custom_font_available = False
except Exception as e:
print(f"Error loading custom font: {e}")
self.custom_font_available = False
def get_font(self, size=10, weight='normal'):
"""Get font tuple for UI elements"""
if self.custom_font_available:
try:
# Use the registered font family name
font_obj = font.Font(family=self.custom_font_family, size=size, weight=weight)
print(f"Created font: {self.custom_font_family}, size={size}, weight={weight}")
return font_obj
except Exception as e:
print(f"Error using custom font: {e}")
# Fall back to a distinctive font so we can see the difference
return font.Font(family='Courier New', size=size, weight=weight)
else:
print(f"Using fallback font: Courier New, size={size}")
return font.Font(family='Courier New', size=size, weight=weight)
def setup_dark_theme(self): def setup_dark_theme(self):
"""Configure dark theme colors""" """Configure dark theme colors"""
style = ttk.Style() style = ttk.Style()
@@ -51,7 +171,7 @@ class SteamWorkshopGUI:
def create_input_section(self, parent): def create_input_section(self, parent):
"""Create the left input section""" """Create the left input section"""
title_label = tk.Label(parent, text="Steam Workshop Collections", title_label = tk.Label(parent, text="Steam Workshop Collections",
font=('Arial', 14, 'bold'), bg='#2b2b2b', fg='#ffffff') font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff')
title_label.pack(pady=(0, 10)) title_label.pack(pady=(0, 10))
# RimWorld game folder input # RimWorld game folder input
@@ -59,12 +179,12 @@ class SteamWorkshopGUI:
rimworld_frame.pack(fill='x', pady=(0, 10)) rimworld_frame.pack(fill='x', pady=(0, 10))
rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:", rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:",
font=('Arial', 9, 'bold'), bg='#2b2b2b', fg='#ffffff') font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff')
rimworld_label.pack(anchor='w') rimworld_label.pack(anchor='w')
rimworld_help = tk.Label(rimworld_frame, rimworld_help = tk.Label(rimworld_frame,
text="Right-click RimWorld in Steam > Manage > Browse local files, copy that path", text="Right-click RimWorld in Steam > Manage > Browse local files, copy that path",
font=('Arial', 8), bg='#2b2b2b', fg='#888888', wraplength=380) font=self.get_font(8), bg='#2b2b2b', fg='#888888', wraplength=380)
rimworld_help.pack(anchor='w', pady=(2, 5)) rimworld_help.pack(anchor='w', pady=(2, 5))
rimworld_input_frame = tk.Frame(rimworld_frame, bg='#2b2b2b') rimworld_input_frame = tk.Frame(rimworld_frame, bg='#2b2b2b')
@@ -72,64 +192,76 @@ class SteamWorkshopGUI:
self.rimworld_var = tk.StringVar() self.rimworld_var = tk.StringVar()
self.rimworld_entry = tk.Entry(rimworld_input_frame, textvariable=self.rimworld_var, self.rimworld_entry = tk.Entry(rimworld_input_frame, textvariable=self.rimworld_var,
font=('Arial', 9), bg='#404040', fg='#ffffff', font=self.get_font(9), bg='#404040', fg='#ffffff',
insertbackground='#ffffff', relief='flat', bd=5) insertbackground='#ffffff', relief='flat', bd=5)
self.rimworld_entry.pack(side='left', fill='x', expand=True) self.rimworld_entry.pack(side='left', fill='x', expand=True)
self.rimworld_entry.bind('<KeyRelease>', self.on_rimworld_path_change) self.rimworld_entry.bind('<KeyRelease>', self.on_rimworld_path_change)
browse_game_btn = tk.Button(rimworld_input_frame, text="Browse", browse_game_btn = tk.Button(rimworld_input_frame, text="Browse",
command=self.browse_rimworld_folder, command=self.browse_rimworld_folder,
bg='#505050', fg='white', font=('Arial', 8), bg='#505050', fg='white', font=self.get_font(8),
relief='flat', padx=10) relief='flat', padx=10)
browse_game_btn.pack(side='right', padx=(5, 0)) browse_game_btn.pack(side='right', padx=(5, 0))
# Workshop folder display (derived from RimWorld path) # Workshop folder display (derived from RimWorld path)
workshop_frame = tk.Frame(parent, bg='#2b2b2b') workshop_frame = tk.Frame(parent, bg='#2b2b2b')
workshop_frame.pack(fill='x', pady=(0, 15)) workshop_frame.pack(fill='x', pady=(0, 10))
workshop_label = tk.Label(workshop_frame, text="Workshop Folder (Auto-derived):", workshop_label = tk.Label(workshop_frame, text="Workshop Folder (Auto-derived):",
font=('Arial', 9, 'bold'), bg='#2b2b2b', fg='#ffffff') font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff')
workshop_label.pack(anchor='w') workshop_label.pack(anchor='w')
self.workshop_var = tk.StringVar(value="Enter RimWorld path above") self.workshop_var = tk.StringVar(value="Enter RimWorld path above")
self.workshop_display = tk.Entry(workshop_frame, textvariable=self.workshop_var, self.workshop_display = tk.Entry(workshop_frame, textvariable=self.workshop_var,
font=('Arial', 8), bg='#404040', fg='#ffffff', font=self.get_font(8), bg='#404040', fg='#ffffff',
insertbackground='#ffffff', relief='flat', bd=5, state='readonly') insertbackground='#ffffff', relief='flat', bd=5)
self.workshop_display.pack(fill='x', pady=(5, 0)) self.workshop_display.pack(fill='x', pady=(5, 0))
self.workshop_display.bind('<KeyRelease>', self.on_workshop_path_change)
# ModsConfig.xml folder display
modsconfig_frame = tk.Frame(parent, bg='#2b2b2b')
modsconfig_frame.pack(fill='x', pady=(0, 15))
modsconfig_label = tk.Label(modsconfig_frame, text="ModsConfig.xml Path:",
font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff')
modsconfig_label.pack(anchor='w')
self.modsconfig_var = tk.StringVar()
self.modsconfig_display = tk.Entry(modsconfig_frame, textvariable=self.modsconfig_var,
font=self.get_font(8), bg='#404040', fg='#ffffff',
insertbackground='#ffffff', relief='flat', bd=5)
self.modsconfig_display.pack(fill='x', pady=(5, 0))
self.modsconfig_display.bind('<KeyRelease>', self.on_modsconfig_path_change)
# Initialize ModsConfig path
self.find_modsconfig_path()
# Core collection # Core collection
self.create_url_input(parent, "Core Collection:", core_url = os.getenv('CORE_COLLECTION_URL', 'https://steamcommunity.com/workshop/filedetails/?id=3521297585')
"https://steamcommunity.com/workshop/filedetails/?id=3521297585") self.create_url_input(parent, "Core Collection:", core_url)
# Content collection # Content collection
self.create_url_input(parent, "Content Collection:", content_url = os.getenv('CONTENT_COLLECTION_URL', 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712')
"steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712") self.create_url_input(parent, "Content Collection:", content_url)
# Cosmetics collection # Cosmetics collection
self.create_url_input(parent, "Cosmetics Collection:", cosmetics_url = os.getenv('COSMETICS_COLLECTION_URL', 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646')
"steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646") self.create_url_input(parent, "Cosmetics Collection:", cosmetics_url)
# Process button # Load Progression Pack button
process_btn = tk.Button(parent, text="Extract Workshop IDs", load_btn = tk.Button(parent, text="Load Progression Pack Complete",
command=self.process_collections, command=self.load_progression_pack,
bg='#0078d4', fg='white', font=('Arial', 10, 'bold'), bg='#0078d4', fg='white', font=self.get_font(12, 'bold'),
relief='flat', padx=20, pady=8) relief='flat', padx=30, pady=12)
process_btn.pack(pady=10) load_btn.pack(pady=30)
# Check local mods button
local_btn = tk.Button(parent, text="List Local Mod Folders",
command=self.list_local_mod_folders,
bg='#28a745', fg='white', font=('Arial', 10, 'bold'),
relief='flat', padx=20, pady=8)
local_btn.pack(pady=5)
def create_url_input(self, parent, label_text, default_url): def create_url_input(self, parent, label_text, default_url):
"""Create a labeled URL input field""" """Create a labeled URL input field"""
label = tk.Label(parent, text=label_text, font=('Arial', 10, 'bold'), label = tk.Label(parent, text=label_text, font=self.get_font(10, 'bold'),
bg='#2b2b2b', fg='#ffffff') bg='#2b2b2b', fg='#ffffff')
label.pack(anchor='w', pady=(10, 5)) label.pack(anchor='w', pady=(10, 5))
entry = tk.Entry(parent, font=('Arial', 9), bg='#404040', fg='#ffffff', entry = tk.Entry(parent, font=self.get_font(9), bg='#404040', fg='#ffffff',
insertbackground='#ffffff', relief='flat', bd=5) insertbackground='#ffffff', relief='flat', bd=5)
entry.pack(fill='x', pady=(0, 10)) entry.pack(fill='x', pady=(0, 10))
entry.insert(0, default_url) entry.insert(0, default_url)
@@ -143,186 +275,26 @@ class SteamWorkshopGUI:
def create_output_section(self, parent): def create_output_section(self, parent):
"""Create the right output section""" """Create the right output section"""
# Main output area title_label = tk.Label(parent, text="Workshop IDs and Package Names",
main_output_frame = tk.Frame(parent, bg='#2b2b2b') font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff')
main_output_frame.pack(fill=tk.BOTH, expand=True)
title_label = tk.Label(main_output_frame, text="Workshop IDs Output",
font=('Arial', 14, 'bold'), bg='#2b2b2b', fg='#ffffff')
title_label.pack(pady=(0, 10)) title_label.pack(pady=(0, 10))
# Output text area with scrollbar # Output text area with scrollbar
self.output_text = scrolledtext.ScrolledText(main_output_frame, self.output_text = scrolledtext.ScrolledText(parent,
font=('Consolas', 10), font=self.get_font(10),
bg='#1e1e1e', fg='#ffffff', bg='#1e1e1e', fg='#ffffff',
insertbackground='#ffffff', insertbackground='#ffffff',
selectbackground='#404040', selectbackground='#404040',
relief='flat', bd=5, height=20) relief='flat', bd=5)
self.output_text.pack(fill=tk.BOTH, expand=True) self.output_text.pack(fill=tk.BOTH, expand=True)
# Local mod folders section def on_workshop_path_change(self, event=None):
local_mods_frame = tk.Frame(parent, bg='#2b2b2b') """Called when workshop path is manually changed"""
local_mods_frame.pack(fill=tk.X, pady=(10, 0)) self.workshop_folder = self.workshop_var.get().strip()
local_title_label = tk.Label(local_mods_frame, text="Local Mod Folders", def on_modsconfig_path_change(self, event=None):
font=('Arial', 12, 'bold'), bg='#2b2b2b', fg='#ffffff') """Called when ModsConfig path is manually changed"""
local_title_label.pack(pady=(0, 5)) self.modsconfig_path = self.modsconfig_var.get().strip()
# Local mods text area
self.local_mods_text = scrolledtext.ScrolledText(local_mods_frame,
font=('Consolas', 9),
bg='#1e1e1e', fg='#ffffff',
insertbackground='#ffffff',
selectbackground='#404040',
relief='flat', bd=5, height=8)
self.local_mods_text.pack(fill=tk.X)
def extract_workshop_id(self, url):
"""Extract workshop ID from Steam URL"""
# Clean up steam:// protocol URLs
if url.startswith('steam://openurl/'):
url = url.replace('steam://openurl/', '')
# Extract ID from URL parameters
try:
parsed_url = urlparse(url)
if 'id=' in parsed_url.query:
query_params = parse_qs(parsed_url.query)
return query_params.get('id', [None])[0]
elif '/filedetails/?id=' in url:
# Direct extraction for simple cases
match = re.search(r'id=(\d+)', url)
if match:
return match.group(1)
except Exception as e:
print(f"Error extracting ID from {url}: {e}")
return None
def process_collections(self):
"""Process all collection URLs and extract workshop IDs"""
self.output_text.delete(1.0, tk.END)
self.output_text.insert(tk.END, "Processing collections...\n\n")
self.root.update()
# Process each collection in a separate thread to avoid blocking UI
threading.Thread(target=self._process_collections_thread, daemon=True).start()
def _process_collections_thread(self):
"""Thread function to process collections"""
try:
for collection_name, entry in self.url_entries.items():
url = entry.get().strip()
if url:
self._safe_update_output(f"=== {collection_name.upper()} COLLECTION ===\n")
workshop_id = self.extract_workshop_id(url)
if workshop_id:
self._safe_update_output(f"Collection ID: {workshop_id}\n")
# Fetch collection contents
workshop_ids = self.fetch_collection_items(workshop_id)
if workshop_ids:
self._safe_update_output(f"Found {len(workshop_ids)} items:\n")
# Check which mods are installed locally if workshop folder is available
local_mods = set()
if self.workshop_folder and os.path.exists(self.workshop_folder):
try:
local_mods = {item for item in os.listdir(self.workshop_folder)
if os.path.isdir(os.path.join(self.workshop_folder, item)) and item.isdigit()}
except:
pass
missing_count = 0
for item_id in workshop_ids:
status = " [INSTALLED]" if item_id in local_mods else " [MISSING]"
if item_id not in local_mods:
missing_count += 1
self._safe_update_output(f"{item_id}{status}\n")
if local_mods:
self._safe_update_output(f"\nSummary: {len(workshop_ids) - missing_count} installed, {missing_count} missing\n")
else:
self._safe_update_output("Could not fetch collection items\n")
else:
self._safe_update_output("Could not extract workshop ID from URL\n")
self._safe_update_output("\n")
self._safe_update_output("Processing complete!")
except Exception as e:
self._safe_update_output(f"Error in processing: {str(e)}\n")
def find_steam_workshop_folder(self):
"""Automatically find the Steam Workshop folder for RimWorld"""
try:
# Method 1: Try to find Steam installation path from registry
steam_paths = []
try:
# Check HKEY_LOCAL_MACHINE first
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Valve\Steam") as key:
install_path = winreg.QueryValueEx(key, "InstallPath")[0]
steam_paths.append(install_path)
except (FileNotFoundError, OSError):
pass
try:
# Check HKEY_CURRENT_USER
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Valve\Steam") as key:
install_path = winreg.QueryValueEx(key, "SteamPath")[0]
steam_paths.append(install_path)
except (FileNotFoundError, OSError):
pass
# Method 2: Check common Steam installation locations
common_paths = [
r"C:\Program Files (x86)\Steam",
r"C:\Program Files\Steam",
r"D:\Steam",
r"E:\Steam",
os.path.expanduser(r"~\Steam")
]
steam_paths.extend(common_paths)
# Method 3: Check if Steam is in PATH
try:
import shutil
steam_exe = shutil.which("steam.exe")
if steam_exe:
steam_paths.append(os.path.dirname(steam_exe))
except:
pass
# Look for the workshop folder in each potential Steam path
for steam_path in steam_paths:
if not steam_path:
continue
workshop_path = os.path.join(steam_path, "steamapps", "workshop", "content", "294100")
if os.path.exists(workshop_path):
return workshop_path
return None
except Exception as e:
print(f"Error finding Steam workshop folder: {e}")
return None
def browse_workshop_folder(self):
"""Allow user to manually select the workshop folder"""
folder = filedialog.askdirectory(
title="Select RimWorld Workshop Folder (steamapps/workshop/content/294100)",
initialdir=self.workshop_folder or "C:\\"
)
if folder:
# Validate that this looks like a workshop folder
if "294100" in folder or messagebox.askyesno(
"Confirm Folder",
f"Are you sure this is the RimWorld workshop folder?\n\n{folder}"
):
self._safe_update_output(f"Workshop folder updated: {folder}\n")
def on_rimworld_path_change(self, event=None): def on_rimworld_path_change(self, event=None):
"""Called when RimWorld path is changed""" """Called when RimWorld path is changed"""
@@ -359,46 +331,356 @@ class SteamWorkshopGUI:
self.rimworld_var.set(folder) self.rimworld_var.set(folder)
self.on_rimworld_path_change() # Update workshop path self.on_rimworld_path_change() # Update workshop path
def list_local_mod_folders(self): def find_modsconfig_path(self):
"""List all local mod folder names in the separate text box""" """Find the ModsConfig.xml file in AppData"""
if not self.workshop_folder or not os.path.exists(self.workshop_folder):
self.local_mods_text.delete(1.0, tk.END)
self.local_mods_text.insert(tk.END, "Workshop folder not found or invalid.\nPlease enter correct RimWorld path above.")
return
threading.Thread(target=self._list_local_mod_folders_thread, daemon=True).start()
def _list_local_mod_folders_thread(self):
"""Thread function to list local mod folders"""
try: try:
# Clear the local mods text box # Get path template from .env
self.local_mods_text.delete(1.0, tk.END) path_template = os.getenv('MODSCONFIG_PATH_TEMPLATE',
r'%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml')
if not os.path.exists(self.workshop_folder): # Expand environment variables
self.local_mods_text.insert(tk.END, f"Workshop folder does not exist:\n{self.workshop_folder}") modsconfig_path = os.path.expandvars(path_template)
return
# Get all subdirectories (each represents a workshop mod) if os.path.exists(modsconfig_path):
local_mods = [] self.modsconfig_path = modsconfig_path
for item in os.listdir(self.workshop_folder): self.modsconfig_var.set(modsconfig_path)
item_path = os.path.join(self.workshop_folder, item) return modsconfig_path
if os.path.isdir(item_path) and item.isdigit(): else:
local_mods.append(item) self.modsconfig_var.set("ModsConfig.xml not found")
return None
local_mods.sort()
# Display in the local mods text box
self.local_mods_text.insert(tk.END, f"Found {len(local_mods)} local workshop mod folders:\n\n")
for mod_id in local_mods:
self.local_mods_text.insert(tk.END, f"{mod_id}\n")
except Exception as e: except Exception as e:
self.local_mods_text.delete(1.0, tk.END) self.modsconfig_var.set(f"Error finding ModsConfig.xml: {str(e)}")
self.local_mods_text.insert(tk.END, f"Error listing local mods: {str(e)}") return None
def check_local_mods(self): def extract_workshop_id(self, url):
"""Check which mods are installed locally in the workshop folder (for backward compatibility)""" """Extract workshop ID from Steam URL"""
self.list_local_mod_folders() # Clean up steam:// protocol URLs
if url.startswith('steam://openurl/'):
url = url.replace('steam://openurl/', '')
# Extract ID from URL parameters
try:
parsed_url = urlparse(url)
if 'id=' in parsed_url.query:
query_params = parse_qs(parsed_url.query)
return query_params.get('id', [None])[0]
elif '/filedetails/?id=' in url:
# Direct extraction for simple cases
match = re.search(r'id=(\d+)', url)
if match:
return match.group(1)
except Exception as e:
print(f"Error extracting ID from {url}: {e}")
return None
def load_progression_pack(self):
"""Load all progression pack collections and extract package names"""
if not self.workshop_folder or not os.path.exists(self.workshop_folder):
self.output_text.delete(1.0, tk.END)
self.output_text.insert(tk.END, "Workshop folder not found!\nPlease enter the correct RimWorld path above.")
return
self.output_text.delete(1.0, tk.END)
self.output_text.insert(tk.END, "Loading Progression Pack Complete...\n\n")
threading.Thread(target=self._load_progression_pack_thread, daemon=True).start()
def _load_progression_pack_thread(self):
"""Thread function to load progression pack"""
try:
all_workshop_ids = []
# Get collection URLs from entries
collections = {
"Core": self.url_entries['core'].get().strip(),
"Content": self.url_entries['content'].get().strip(),
"Cosmetics": self.url_entries['cosmetics'].get().strip()
}
# Fetch all workshop IDs from collections
for collection_name, url in collections.items():
if url:
self._safe_update_output(f"Fetching {collection_name} collection...\n")
workshop_id = self.extract_workshop_id(url)
if workshop_id:
workshop_ids = self.fetch_collection_items(workshop_id)
all_workshop_ids.extend(workshop_ids)
self._safe_update_output(f"Found {len(workshop_ids)} items in {collection_name}\n")
# Remove duplicates
unique_ids = list(set(all_workshop_ids))
self._safe_update_output(f"\nTotal unique workshop items: {len(unique_ids)}\n")
self._safe_update_output("Extracting package names from About.xml files...\n\n")
# Extract package names from About.xml files
results = []
for workshop_id in sorted(unique_ids):
package_name = self.get_package_name_from_about_xml(workshop_id)
results.append((workshop_id, package_name))
# Display results
self._safe_update_output("=== PROGRESSION PACK COMPLETE ===\n\n")
self._safe_update_output(f"{'Workshop ID':<15} | Package Name\n")
self._safe_update_output("-" * 80 + "\n")
for workshop_id, package_name in results:
self._safe_update_output(f"{workshop_id:<15} | {package_name}\n")
self._safe_update_output(f"\nTotal mods processed: {len(results)}\n")
# Create ProgressionVanilla.rml file
self.create_rml_file(results)
except Exception as e:
self._safe_update_output(f"Error loading progression pack: {str(e)}\n")
def create_rml_file(self, mod_data):
"""Create ProgressionVanilla.rml file with workshop IDs and names"""
try:
# Get ModLists directory path
modlists_path = os.path.expandvars(r"%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\ModLists")
# Create directory if it doesn't exist
os.makedirs(modlists_path, exist_ok=True)
rml_file_path = os.path.join(modlists_path, "ProgressionVanilla.rml")
# Create XML content
xml_content = self.generate_rml_xml(mod_data)
# Write to file
with open(rml_file_path, 'w', encoding='utf-8') as f:
f.write(xml_content)
self._safe_update_output(f"\nProgressionVanilla.rml created successfully at:\n{rml_file_path}\n")
except Exception as e:
self._safe_update_output(f"Error creating RML file: {str(e)}\n")
def generate_rml_xml(self, mod_data):
"""Generate the XML content for the .rml file"""
# Get core mods from ModsConfig.xml and RimWorld Data folder
core_mods = self.get_core_mods_from_config()
# Extract mod names from About.xml files for workshop mods
mod_names = []
for workshop_id, package_id in mod_data:
mod_name = self.get_mod_name_from_about_xml(workshop_id)
mod_names.append(mod_name)
xml_lines = [
'<?xml version="1.0" encoding="utf-8"?>',
'<savedModList>',
'\t<meta>',
'\t\t<gameVersion>1.6.4633 rev1261</gameVersion>',
'\t\t<modIds>'
]
# Add core mods first
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
# Add workshop mod package IDs to meta section
for workshop_id, package_id in mod_data:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
xml_lines.extend([
'\t\t</modIds>',
'\t\t<modSteamIds>'
])
# 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<li>0</li>')
# Add workshop IDs to meta section
for workshop_id, package_id in mod_data:
xml_lines.append(f'\t\t\t<li>{workshop_id}</li>')
xml_lines.extend([
'\t\t</modSteamIds>',
'\t\t<modNames>'
])
# Add core mod names first
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
# Add workshop mod names to meta section
for mod_name in mod_names:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
xml_lines.extend([
'\t\t</modNames>',
'\t</meta>',
'\t<modList>',
'\t\t<ids>'
])
# Add core mods first to modList section
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
# Add workshop mod package IDs to modList section
for workshop_id, package_id in mod_data:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
xml_lines.extend([
'\t\t</ids>',
'\t\t<names>'
])
# Add core mod names first to modList section
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
# Add workshop mod names to modList section
for mod_name in mod_names:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
xml_lines.extend([
'\t\t</names>',
'\t</modList>',
'</savedModList>'
])
return '\n'.join(xml_lines)
def get_core_mods_from_config(self):
"""Get core mods from ModsConfig.xml knownExpansions section and their info from RimWorld Data folder"""
core_mods = []
try:
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
self._safe_update_output("ModsConfig.xml not found, using default core mods\n")
return [('ludeon.rimworld', 'RimWorld')]
# Parse ModsConfig.xml
tree = ET.parse(self.modsconfig_path)
root = tree.getroot()
# Get RimWorld Data folder path from the RimWorld game path
rimworld_data_path = None
if self.rimworld_var.get().strip():
rimworld_game_path = self.rimworld_var.get().strip()
rimworld_data_path = os.path.join(rimworld_game_path, "Data")
# Find knownExpansions section
known_expansions_element = root.find('knownExpansions')
if known_expansions_element is not None:
for li in known_expansions_element.findall('li'):
expansion_id = li.text
if expansion_id:
mod_name = self.get_core_mod_name(expansion_id.strip(), rimworld_data_path)
core_mods.append((expansion_id.strip(), mod_name))
if not core_mods:
# Fallback if no expansions found
core_mods = [('ludeon.rimworld', 'RimWorld')]
self._safe_update_output(f"Found {len(core_mods)} core expansions from ModsConfig.xml\n")
except Exception as e:
self._safe_update_output(f"Error reading core mods: {str(e)}\n")
core_mods = [('ludeon.rimworld', 'RimWorld')]
return core_mods
def get_core_mod_name(self, mod_id, rimworld_data_path):
"""Get mod name from RimWorld Data folder About.xml"""
try:
if not rimworld_data_path or not os.path.exists(rimworld_data_path):
return self.get_default_mod_name(mod_id)
# Look for mod folder in Data directory
# Core mods are usually in subfolders like Core, Royalty, etc.
possible_folders = [
mod_id.replace('ludeon.rimworld.', '').replace('ludeon.rimworld', 'Core'),
mod_id.replace('ludeon.rimworld.', '').capitalize(),
mod_id.split('.')[-1].capitalize() if '.' in mod_id else mod_id
]
for folder_name in possible_folders:
about_xml_path = os.path.join(rimworld_data_path, folder_name, "About", "About.xml")
if os.path.exists(about_xml_path):
try:
tree = ET.parse(about_xml_path)
root = tree.getroot()
name_element = root.find('name')
if name_element is not None and name_element.text:
return name_element.text.strip()
except ET.ParseError:
continue
# If not found in Data folder, use default name
return self.get_default_mod_name(mod_id)
except Exception as e:
return self.get_default_mod_name(mod_id)
def get_default_mod_name(self, mod_id):
"""Get default display name for core mods"""
default_names = {
'ludeon.rimworld': 'RimWorld',
'ludeon.rimworld.royalty': 'Royalty',
'ludeon.rimworld.ideology': 'Ideology',
'ludeon.rimworld.biotech': 'Biotech',
'ludeon.rimworld.anomaly': 'Anomaly',
'ludeon.rimworld.odyssey': 'Odyssey'
}
return default_names.get(mod_id, mod_id)
def get_mod_name_from_about_xml(self, workshop_id):
"""Extract mod name from About/About.xml file"""
try:
about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml")
if not os.path.exists(about_xml_path):
return f"Workshop ID {workshop_id}"
# Parse the XML file
tree = ET.parse(about_xml_path)
root = tree.getroot()
# Look for name element
name_element = root.find('name')
if name_element is not None and name_element.text:
return name_element.text.strip()
else:
return f"Workshop ID {workshop_id}"
except ET.ParseError:
return f"Workshop ID {workshop_id}"
except Exception as e:
return f"Workshop ID {workshop_id}"
def get_package_name_from_about_xml(self, workshop_id):
"""Extract package name from About/About.xml file"""
try:
about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml")
if not os.path.exists(about_xml_path):
return "About.xml not found"
# Parse the XML file
tree = ET.parse(about_xml_path)
root = tree.getroot()
# Look for packageId element
package_id_element = root.find('packageId')
if package_id_element is not None and package_id_element.text:
return package_id_element.text.strip()
else:
return "packageId not found"
except ET.ParseError:
return "XML parse error"
except Exception as e:
return f"Error: {str(e)}"
def _safe_update_output(self, text): def _safe_update_output(self, text):
"""Safely update output text without recursion""" """Safely update output text without recursion"""
@@ -485,14 +767,6 @@ class SteamWorkshopGUI:
self._safe_update_output(f"Error fetching collection: {str(e)}\n") self._safe_update_output(f"Error fetching collection: {str(e)}\n")
return [] return []
def _update_output(self, text):
"""Update output text (called from main thread)"""
try:
self.output_text.insert(tk.END, text)
self.output_text.see(tk.END)
except Exception:
pass # Prevent recursion errors
def main(): def main():
root = tk.Tk() root = tk.Tk()
app = SteamWorkshopGUI(root) app = SteamWorkshopGUI(root)