""" Entropia Universe Icon Extractor A standalone tool for extracting and converting game icons from cache. Description: Standalone GUI tool to extract TGA icons from Entropia Universe cache, convert them to PNG, and optionally upscale them for better quality. Usage: python standalone_icon_extractor.py Author: LemonNexus """ import sys import logging from pathlib import Path from typing import Optional, List, Tuple from dataclasses import dataclass from enum import Enum try: from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QListWidget, QListWidgetItem, QFileDialog, QProgressBar, QGroupBox, QMessageBox, QCheckBox, QSpinBox, QSplitter ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSettings from PyQt6.QtGui import QIcon, QPixmap PYQT_AVAILABLE = True except ImportError: PYQT_AVAILABLE = False print("PyQt6 not available. Install with: pip install PyQt6") try: from PIL import Image, ImageFilter PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False print("PIL not available. Install with: pip install Pillow") import numpy as np # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) class TGAHeader: """TGA file header structure.""" def __init__(self, data: bytes): self.id_length = data[0] self.color_map_type = data[1] self.image_type = data[2] self.color_map_origin = int.from_bytes(data[3:5], 'little') self.color_map_length = int.from_bytes(data[5:7], 'little') self.color_map_depth = data[7] self.x_origin = int.from_bytes(data[8:10], 'little') self.y_origin = int.from_bytes(data[10:12], 'little') self.width = int.from_bytes(data[12:14], 'little') self.height = int.from_bytes(data[14:16], 'little') self.pixel_depth = data[16] self.image_descriptor = data[17] def __str__(self): return f"{self.width}x{self.height}, {self.pixel_depth}bpp" class TGAConverter: """Converter for TGA files to PNG with canvas and upscaling options.""" def __init__(self, output_dir: Optional[Path] = None): self.output_dir = output_dir or Path.home() / "Documents" / "Entropia Universe" / "Icons" self.output_dir.mkdir(parents=True, exist_ok=True) self._cache_path: Optional[Path] = None def find_cache_folder(self) -> Optional[Path]: """Find the Entropia Universe icon cache folder.""" possible_paths = [ Path("C:") / "ProgramData" / "Entropia Universe" / "public_users_data" / "cache" / "icon", Path.home() / "Documents" / "Entropia Universe" / "cache" / "icons", ] for path in possible_paths: if path.exists(): # Check if this folder or any subfolder has .tga files if list(path.rglob("*.tga")): self._cache_path = path return path return None def read_tga_header(self, filepath: Path) -> Optional[TGAHeader]: """Read TGA header from file.""" try: with open(filepath, 'rb') as f: header_data = f.read(18) if len(header_data) < 18: return None return TGAHeader(header_data) except Exception as e: logger.error(f"Error reading TGA header: {e}") return None def convert_tga_to_png( self, tga_path: Path, output_name: Optional[str] = None, canvas_size: Optional[Tuple[int, int]] = None, upscale: bool = False, upscale_method: str = 'nearest' ) -> Optional[Path]: """Convert a TGA file to PNG.""" try: # Try PIL first image = Image.open(tga_path) if image.mode != 'RGBA': image = image.convert('RGBA') # Apply canvas sizing if requested if canvas_size: do_upscale = upscale and upscale_method != 'none' image = self._apply_canvas(image, canvas_size, do_upscale, upscale_method) # Save if output_name is None: output_name = tga_path.stem output_path = self.output_dir / f"{output_name}.png" image.save(output_path, 'PNG') return output_path except Exception as e: logger.error(f"Conversion failed: {e}") return None def _apply_canvas( self, image: Image.Image, canvas_size: Tuple[int, int], upscale: bool = False, upscale_method: str = 'nearest' ) -> Image.Image: """Place image centered on a canvas.""" canvas_w, canvas_h = canvas_size img_w, img_h = image.size # Create transparent canvas canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) # Calculate scaling if upscale: max_size = int(min(canvas_w, canvas_h) * 0.85) scale = min(max_size / img_w, max_size / img_h) if scale > 1: new_w = int(img_w * scale) new_h = int(img_h * scale) if upscale_method == 'nearest': image = image.resize((new_w, new_h), Image.Resampling.NEAREST) elif upscale_method == 'hq4x': int_scale = max(2, int(scale)) temp_w = img_w * int_scale temp_h = img_h * int_scale image = image.resize((temp_w, temp_h), Image.Resampling.NEAREST) image = image.resize((new_w, new_h), Image.Resampling.LANCZOS) image = image.filter(ImageFilter.EDGE_ENHANCE_MORE) else: # lanczos image = image.resize((new_w, new_h), Image.Resampling.LANCZOS) image = image.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3)) img_w, img_h = new_w, new_h # Center on canvas x = (canvas_w - img_w) // 2 y = (canvas_h - img_h) // 2 canvas.paste(image, (x, y), image if image.mode == 'RGBA' else None) return canvas class ConversionWorker(QThread): """Background worker for batch conversion.""" progress = pyqtSignal(str) file_done = pyqtSignal(str, str) # filename, output_path finished = pyqtSignal(int, int) # success, total error = pyqtSignal(str) def __init__( self, files: List[Path], converter: TGAConverter, canvas_size: Optional[Tuple[int, int]], upscale_method: str ): super().__init__() self.files = files self.converter = converter self.canvas_size = canvas_size self.upscale_method = upscale_method self._running = True def run(self): """Run conversion.""" try: success = 0 total = len(self.files) for i, filepath in enumerate(self.files): if not self._running: break self.progress.emit(f"[{i+1}/{total}] {filepath.name}") output = self.converter.convert_tga_to_png( filepath, canvas_size=self.canvas_size, upscale=True, upscale_method=self.upscale_method ) if output: success += 1 self.file_done.emit(filepath.name, str(output)) self.finished.emit(success, total) except Exception as e: self.error.emit(str(e)) def stop(self): self._running = False class IconExtractorWindow(QMainWindow): """Main window for the standalone icon extractor.""" def __init__(self): super().__init__() self.setWindowTitle("🎮 Entropia Universe Icon Extractor") self.setMinimumSize(900, 700) self.converter = TGAConverter() self.worker: Optional[ConversionWorker] = None self.found_files: List[Path] = [] self.current_subfolder: Optional[Path] = None # Hardcoded base cache path self.base_cache_path = Path("C:/ProgramData/Entropia Universe/public_users_data/cache/icon") self.settings = QSettings("Lemontropia", "IconExtractor") self._setup_ui() self._load_settings() self._detect_subfolders() def _setup_ui(self): """Setup the UI.""" central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) layout.setContentsMargins(15, 15, 15, 15) layout.setSpacing(12) # Header header = QLabel("🎮 Entropia Universe Icon Extractor") header.setStyleSheet("font-size: 20px; font-weight: bold; color: #4caf50;") layout.addWidget(header) desc = QLabel( "Extract and convert item icons from Entropia Universe game cache.\n" "Icons are saved as PNG files with transparent backgrounds." ) desc.setStyleSheet("color: #888; padding: 5px;") desc.setWordWrap(True) layout.addWidget(desc) # Main splitter splitter = QSplitter(Qt.Orientation.Horizontal) # Left panel - Settings left_panel = QWidget() left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(0, 0, 0, 0) # Cache folder cache_group = QGroupBox("📁 Cache Folder") cache_layout = QVBoxLayout(cache_group) # Base path (hardcoded) base_label = QLabel("Base Path:") cache_layout.addWidget(base_label) self.cache_label = QLabel(str(self.base_cache_path)) self.cache_label.setStyleSheet("font-family: Consolas; color: #888; padding: 5px; background: #1a1a1a; border-radius: 3px;") cache_layout.addWidget(self.cache_label) # Subfolder selector subfolder_layout = QHBoxLayout() subfolder_layout.addWidget(QLabel("Version Folder:")) self.subfolder_combo = QComboBox() self.subfolder_combo.setMinimumWidth(200) self.subfolder_combo.currentIndexChanged.connect(self._on_subfolder_changed) subfolder_layout.addWidget(self.subfolder_combo) refresh_btn = QPushButton("🔄 Refresh") refresh_btn.clicked.connect(self._detect_subfolders) subfolder_layout.addWidget(refresh_btn) subfolder_layout.addStretch() cache_layout.addLayout(subfolder_layout) # All subfolders checkbox self.all_subfolders_check = QCheckBox("Include ALL subfolders (merge everything)") self.all_subfolders_check.setToolTip("If checked, will find TGA files from ALL version subfolders") self.all_subfolders_check.stateChanged.connect(self._on_all_subfolders_changed) cache_layout.addWidget(self.all_subfolders_check) cache_btn_layout = QHBoxLayout() scan_btn = QPushButton("🔍 Auto-Detect") scan_btn.clicked.connect(self._auto_scan) cache_btn_layout.addWidget(scan_btn) browse_btn = QPushButton("Browse...") browse_btn.clicked.connect(self._browse_cache) cache_btn_layout.addWidget(browse_btn) cache_btn_layout.addStretch() cache_layout.addLayout(cache_btn_layout) left_layout.addWidget(cache_group) # Output folder output_group = QGroupBox("💾 Output Folder") output_layout = QVBoxLayout(output_group) self.output_label = QLabel(str(self.converter.output_dir)) self.output_label.setStyleSheet("font-family: Consolas; color: #888; padding: 5px; background: #1a1a1a; border-radius: 3px;") output_layout.addWidget(self.output_label) output_btn = QPushButton("Change Output Folder...") output_btn.clicked.connect(self._browse_output) output_layout.addWidget(output_btn) left_layout.addWidget(output_group) # Settings settings_group = QGroupBox("⚙️ Conversion Settings") settings_layout = QVBoxLayout(settings_group) # Canvas size size_layout = QHBoxLayout() size_layout.addWidget(QLabel("Canvas Size:")) self.size_combo = QComboBox() self.size_combo.addItem("Original (no canvas)", None) self.size_combo.addItem("64x64", (64, 64)) self.size_combo.addItem("128x128", (128, 128)) self.size_combo.addItem("256x256", (256, 256)) self.size_combo.addItem("280x280 (Forum)", (280, 280)) self.size_combo.addItem("320x320", (320, 320)) self.size_combo.addItem("512x512", (512, 512)) self.size_combo.setCurrentIndex(5) # Default 320x320 size_layout.addWidget(self.size_combo) size_layout.addStretch() settings_layout.addLayout(size_layout) # Upscale method method_layout = QHBoxLayout() method_layout.addWidget(QLabel("Upscale:")) self.method_combo = QComboBox() self.method_combo.addItem("❌ No Upscaling", "none") self.method_combo.addItem("Sharp Pixels (NEAREST)", "nearest") self.method_combo.addItem("Smooth (HQ4x-style)", "hq4x") self.method_combo.addItem("Photorealistic (LANCZOS)", "lanczos") self.method_combo.setToolTip( "None: Keep original size\n" "NEAREST: Sharp edges (pixel art)\n" "HQ4x: Smooth (game icons)\n" "LANCZOS: Very smooth (photos)" ) method_layout.addWidget(self.method_combo) method_layout.addStretch() settings_layout.addLayout(method_layout) left_layout.addWidget(settings_group) # Convert button self.convert_btn = QPushButton("🚀 Convert All Icons") self.convert_btn.setMinimumHeight(60) self.convert_btn.setStyleSheet(""" QPushButton { background-color: #0d47a1; font-weight: bold; font-size: 16px; border-radius: 5px; } QPushButton:hover { background-color: #1565c0; } QPushButton:disabled { background-color: #333; color: #666; } """) self.convert_btn.clicked.connect(self._start_conversion) left_layout.addWidget(self.convert_btn) # Progress self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) left_layout.addWidget(self.progress_bar) self.status_label = QLabel("Ready") self.status_label.setStyleSheet("color: #888; padding: 5px;") left_layout.addWidget(self.status_label) left_layout.addStretch() splitter.addWidget(left_panel) # Right panel - File list right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(0, 0, 0, 0) files_group = QGroupBox("📄 Found Icons") files_layout = QVBoxLayout(files_group) self.files_count_label = QLabel("No files found") files_layout.addWidget(self.files_count_label) self.files_list = QListWidget() self.files_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) files_layout.addWidget(self.files_list) # Selection buttons sel_layout = QHBoxLayout() select_all_btn = QPushButton("Select All") select_all_btn.clicked.connect(self.files_list.selectAll) sel_layout.addWidget(select_all_btn) select_none_btn = QPushButton("Select None") select_none_btn.clicked.connect(self.files_list.clearSelection) sel_layout.addWidget(select_none_btn) sel_layout.addStretch() open_folder_btn = QPushButton("📂 Open Output Folder") open_folder_btn.clicked.connect(self._open_output_folder) sel_layout.addWidget(open_folder_btn) files_layout.addLayout(sel_layout) right_layout.addWidget(files_group) splitter.addWidget(right_panel) splitter.setSizes([350, 550]) layout.addWidget(splitter, 1) # Footer footer = QLabel("Entropia Universe Icon Extractor | Standalone Tool") footer.setStyleSheet("color: #555; font-size: 11px; padding: 5px;") footer.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(footer) def _load_settings(self): """Load saved settings.""" # Output folder saved_output = self.settings.value("output_dir", str(self.converter.output_dir)) self.converter.output_dir = Path(saved_output) self.output_label.setText(saved_output) # Canvas size size_index = int(self.settings.value("canvas_size_index", 5)) self.size_combo.setCurrentIndex(size_index) # Upscale method method_index = int(self.settings.value("upscale_method_index", 0)) self.method_combo.setCurrentIndex(method_index) def _save_settings(self): """Save current settings.""" self.settings.setValue("output_dir", str(self.converter.output_dir)) self.settings.setValue("canvas_size_index", self.size_combo.currentIndex()) self.settings.setValue("upscale_method_index", self.method_combo.currentIndex()) def _detect_subfolders(self): """Detect version subfolders in the cache directory.""" self.subfolder_combo.clear() if not self.base_cache_path.exists(): self.cache_label.setText(f"❌ Not found: {self.base_cache_path}") self.status_label.setText("Cache folder not found - check if path is correct") return # Find all subfolders that contain TGA files subfolders = [] for item in self.base_cache_path.iterdir(): if item.is_dir(): # Check if this folder has TGA files tga_count = len(list(item.glob("*.tga"))) if tga_count > 0: subfolders.append((item.name, tga_count, item)) if not subfolders: self.cache_label.setText(f"⚠️ No subfolders with TGA files in {self.base_cache_path}") self.status_label.setText("No version folders found") return # Sort by name (version) subfolders.sort(key=lambda x: x[0]) # Add to combo for name, count, path in subfolders: self.subfolder_combo.addItem(f"{name} ({count} icons)", str(path)) # Add "All folders" option at top total_icons = sum(s[1] for s in subfolders) self.subfolder_combo.insertItem(0, f"📁 All Folders ({total_icons} icons)", "all") self.subfolder_combo.setCurrentIndex(0) self.cache_label.setText(f"✅ {self.base_cache_path}") self.status_label.setText(f"Found {len(subfolders)} version folders") # Load files self._refresh_file_list() def _on_subfolder_changed(self): """Handle subfolder selection change.""" self._refresh_file_list() def _on_all_subfolders_changed(self): """Handle 'all subfolders' checkbox change.""" self.subfolder_combo.setEnabled(not self.all_subfolders_check.isChecked()) self._refresh_file_list() def _auto_scan(self): """Auto-detect cache folder - just refreshes subfolder list.""" self.status_label.setText("Scanning for version folders...") self._detect_subfolders() def _browse_cache(self): """Browse for cache folder.""" folder = QFileDialog.getExistingDirectory( self, "Select Entropia Universe Cache Folder", str(self.base_cache_path.parent) ) if folder: self.base_cache_path = Path(folder) self.cache_label.setText(str(self.base_cache_path)) self._detect_subfolders() def _refresh_file_list(self): """Refresh the list of found files based on current selection.""" self.files_list.clear() self.found_files = [] if not self.base_cache_path.exists(): return # Determine which folders to scan if self.all_subfolders_check.isChecked(): # Scan all subfolders folders_to_scan = [d for d in self.base_cache_path.iterdir() if d.is_dir()] else: # Scan selected subfolder path_data = self.subfolder_combo.currentData() if path_data == "all" or path_data is None: folders_to_scan = [d for d in self.base_cache_path.iterdir() if d.is_dir()] else: folders_to_scan = [Path(path_data)] # Collect TGA files tga_files = [] for folder in folders_to_scan: if folder.exists(): tga_files.extend(folder.glob("*.tga")) self.files_count_label.setText(f"Found {len(tga_files)} icon files") self.status_label.setText(f"Found {len(tga_files)} files") for tga_file in sorted(tga_files): # Show folder prefix for clarity try: rel_path = f"{tga_file.parent.name}/{tga_file.name}" except: rel_path = tga_file.name item = QListWidgetItem(rel_path) item.setData(Qt.ItemDataRole.UserRole, str(tga_file)) # Get info header = self.converter.read_tga_header(tga_file) if header: item.setToolTip(f"{header.width}x{header.height}, {header.pixel_depth}bpp") self.files_list.addItem(item) self.found_files.append(tga_file) self.convert_btn.setEnabled(len(tga_files) > 0) def _browse_output(self): """Browse for output folder.""" folder = QFileDialog.getExistingDirectory( self, "Select Output Folder", str(self.converter.output_dir) ) if folder: self.converter.output_dir = Path(folder) self.output_label.setText(folder) self._save_settings() def _start_conversion(self): """Start batch conversion.""" # Get selected files or all files selected_items = self.files_list.selectedItems() if selected_items: files_to_convert = [ Path(item.data(Qt.ItemDataRole.UserRole)) for item in selected_items ] else: files_to_convert = self.found_files if not files_to_convert: QMessageBox.warning(self, "No Files", "No files selected for conversion.") return # Get settings canvas_size = self.size_combo.currentData() upscale_method = self.method_combo.currentData() # Save settings self._save_settings() # Setup UI self.convert_btn.setEnabled(False) self.convert_btn.setText("⏳ Converting...") self.progress_bar.setRange(0, len(files_to_convert)) self.progress_bar.setValue(0) self.progress_bar.setVisible(True) # Start worker self.worker = ConversionWorker( files_to_convert, self.converter, canvas_size, upscale_method ) self.worker.progress.connect(self._on_progress) self.worker.file_done.connect(self._on_file_done) self.worker.finished.connect(self._on_finished) self.worker.error.connect(self._on_error) self.worker.start() def _on_progress(self, msg: str): self.status_label.setText(msg) self.progress_bar.setValue(self.progress_bar.value() + 1) def _on_file_done(self, filename: str, output_path: str): logger.info(f"Converted: {filename} -> {output_path}") def _on_finished(self, success: int, total: int): self.convert_btn.setEnabled(True) self.convert_btn.setText("🚀 Convert All Icons") self.progress_bar.setVisible(False) self.status_label.setText(f"✅ Converted {success}/{total} files") QMessageBox.information( self, "Conversion Complete", f"Successfully converted {success} of {total} icons.\n\n" f"Output folder:\n{self.converter.output_dir}" ) def _on_error(self, error_msg: str): self.convert_btn.setEnabled(True) self.convert_btn.setText("🚀 Convert All Icons") self.progress_bar.setVisible(False) self.status_label.setText(f"❌ Error: {error_msg}") QMessageBox.critical(self, "Error", f"Conversion failed:\n{error_msg}") def _open_output_folder(self): """Open output folder in file manager.""" import os import subprocess path = str(self.converter.output_dir) if os.name == 'nt': os.startfile(path) elif sys.platform == 'darwin': subprocess.run(['open', path]) else: subprocess.run(['xdg-open', path]) def closeEvent(self, event): """Save settings on close.""" self._save_settings() if self.worker and self.worker.isRunning(): self.worker.stop() self.worker.wait(1000) event.accept() def main(): """Main entry point.""" if not PYQT_AVAILABLE: print("ERROR: PyQt6 is required. Install with: pip install PyQt6") sys.exit(1) if not PIL_AVAILABLE: print("ERROR: Pillow is required. Install with: pip install Pillow") sys.exit(1) app = QApplication(sys.argv) app.setStyle('Fusion') # Dark theme app.setStyleSheet(""" QMainWindow, QDialog { background-color: #1a1a1a; } QWidget { background-color: #1a1a1a; color: #e0e0e0; } QGroupBox { font-weight: bold; border: 1px solid #333; border-radius: 5px; margin-top: 10px; padding-top: 10px; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; } QPushButton { background-color: #333; border: 1px solid #555; padding: 8px 15px; border-radius: 3px; } QPushButton:hover { background-color: #444; } QComboBox { background-color: #2a2a2a; border: 1px solid #555; padding: 5px; min-width: 150px; } QListWidget { background-color: #222; border: 1px solid #444; border-radius: 3px; } QListWidget::item { padding: 5px; } QListWidget::item:selected { background-color: #0d47a1; } QProgressBar { border: 1px solid #444; border-radius: 3px; text-align: center; } QProgressBar::chunk { background-color: #4caf50; } """) window = IconExtractorWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()