diff --git a/modules/tga_converter.py b/modules/tga_converter.py new file mode 100644 index 0000000..6bc88aa --- /dev/null +++ b/modules/tga_converter.py @@ -0,0 +1,392 @@ +""" +Lemontropia Suite - TGA Icon Converter +Convert Entropia Universe cached .tga icons to PNG format. + +The game stores item icons as .tga files in the cache folder. +This module converts them to PNG for easier use. +""" + +import struct +import logging +from pathlib import Path +from typing import Optional, List, Tuple, Dict +from dataclasses import dataclass +import numpy as np + +try: + from PIL import Image + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + Image = None + +logger = logging.getLogger(__name__) + + +@dataclass +class TGAFile: + """Represents a TGA file with metadata.""" + filepath: Path + width: int + height: int + pixel_depth: int + has_alpha: bool + + def __str__(self) -> str: + return f"TGA({self.width}x{self.height}, {self.pixel_depth}bpp, alpha={self.has_alpha})" + + +class TGAConverter: + """ + Converter for Entropia Universe TGA icon files. + + Usage: + converter = TGAConverter() + + # Convert single file + png_path = converter.convert_tga_to_png("item_icon.tga") + + # Batch convert all cached icons + results = converter.convert_cache_folder() + """ + + # Common Entropia Universe cache locations + DEFAULT_CACHE_PATHS = [ + Path.home() / "Documents" / "Entropia Universe" / "cache" / "icons", + Path.home() / "Documents" / "Entropia Universe" / "cache", + Path("C:") / "ProgramData" / "Entropia Universe" / "cache" / "icons", + Path("C:") / "Program Files (x86)" / "Entropia Universe" / "cache" / "icons", + ] + + def __init__(self, output_dir: Optional[Path] = None): + """ + Initialize TGA converter. + + Args: + output_dir: Directory for converted PNG files. + Default: Documents/Entropia Universe/Icons/ + """ + if output_dir is None: + output_dir = Path.home() / "Documents" / "Entropia Universe" / "Icons" + + self.output_dir = output_dir + self.output_dir.mkdir(parents=True, exist_ok=True) + + self._cache_path: Optional[Path] = None + + if not PIL_AVAILABLE: + logger.warning("PIL/Pillow not available. Install with: pip install Pillow") + + def find_cache_folder(self) -> Optional[Path]: + """ + Find the Entropia Universe icon cache folder. + + Returns: + Path to cache folder or None if not found + """ + # Check default locations + for path in self.DEFAULT_CACHE_PATHS: + if path.exists(): + logger.info(f"Found cache folder: {path}") + self._cache_path = path + return path + + # Try to find by looking for .tga files + logger.info("Searching for .tga files...") + docs_path = Path.home() / "Documents" / "Entropia Universe" + if docs_path.exists(): + for tga_file in docs_path.rglob("*.tga"): + cache_path = tga_file.parent + logger.info(f"Found cache folder via search: {cache_path}") + self._cache_path = cache_path + return cache_path + + logger.warning("Could not find Entropia Universe cache folder") + return None + + def read_tga_header(self, filepath: Path) -> Optional[TGAFile]: + """ + Read TGA file header. + + Args: + filepath: Path to .tga file + + Returns: + TGAFile with metadata or None if invalid + """ + try: + with open(filepath, 'rb') as f: + # Read TGA header (18 bytes) + header = f.read(18) + + if len(header) < 18: + logger.warning(f"Invalid TGA file (too small): {filepath}") + return None + + # Unpack header + # Format: id_length, color_map_type, image_type, color_map_spec(5), + # x_origin(2), y_origin(2), width(2), height(2), pixel_depth, descriptor + id_length = header[0] + color_map_type = header[1] + image_type = header[2] + + # Skip color map specification (5 bytes) + x_origin = struct.unpack(' 0) + + return TGAFile( + filepath=filepath, + width=width, + height=height, + pixel_depth=pixel_depth, + has_alpha=has_alpha + ) + + except Exception as e: + logger.error(f"Error reading TGA header: {e}") + return None + + def read_tga_pixels(self, tga_file: TGAFile) -> Optional[np.ndarray]: + """ + Read pixel data from TGA file. + + Args: + tga_file: TGAFile with metadata + + Returns: + Numpy array of pixels (H, W, C) or None + """ + try: + with open(tga_file.filepath, 'rb') as f: + # Skip header + f.seek(18) + + # Skip ID field if present + header = f.read(18) + id_length = header[0] if len(header) >= 18 else 0 + if id_length > 0: + f.seek(18 + id_length) + + # Calculate pixel data size + bytes_per_pixel = tga_file.pixel_depth // 8 + pixel_data_size = tga_file.width * tga_file.height * bytes_per_pixel + + # Read pixel data + pixel_data = f.read(pixel_data_size) + + if len(pixel_data) < pixel_data_size: + logger.warning(f"Incomplete TGA pixel data: {tga_file.filepath}") + return None + + # Convert to numpy array + pixels = np.frombuffer(pixel_data, dtype=np.uint8) + + # Reshape based on format + if bytes_per_pixel == 4: + # BGRA format + pixels = pixels.reshape((tga_file.height, tga_file.width, 4)) + # Convert BGRA to RGBA + pixels = pixels[:, :, [2, 1, 0, 3]] + elif bytes_per_pixel == 3: + # BGR format + pixels = pixels.reshape((tga_file.height, tga_file.width, 3)) + # Convert BGR to RGB + pixels = pixels[:, :, [2, 1, 0]] + elif bytes_per_pixel == 2: + # 16-bit format - convert to RGB + pixels = pixels.view(np.uint16).reshape((tga_file.height, tga_file.width)) + # Simple conversion (not accurate but works) + pixels = np.stack([ + ((pixels >> 10) & 0x1F) << 3, + ((pixels >> 5) & 0x1F) << 3, + (pixels & 0x1F) << 3 + ], axis=-1).astype(np.uint8) + else: + logger.warning(f"Unsupported pixel depth: {tga_file.pixel_depth}") + return None + + # Flip vertically (TGA stores bottom-to-top) + pixels = np.flipud(pixels) + + return pixels + + except Exception as e: + logger.error(f"Error reading TGA pixels: {e}") + return None + + def convert_tga_to_png(self, tga_path: Path, output_name: Optional[str] = None) -> Optional[Path]: + """ + Convert a TGA file to PNG. + + Args: + tga_path: Path to .tga file + output_name: Optional output filename (without extension) + + Returns: + Path to converted PNG file or None if failed + """ + if not PIL_AVAILABLE: + logger.error("PIL/Pillow required for TGA to PNG conversion") + return None + + try: + # Read TGA + tga_file = self.read_tga_header(tga_path) + if not tga_file: + return None + + logger.info(f"Converting: {tga_path.name} ({tga_file})") + + pixels = self.read_tga_pixels(tga_file) + if pixels is None: + return None + + # Create PIL Image + if pixels.shape[2] == 4: + # RGBA + image = Image.fromarray(pixels, 'RGBA') + else: + # RGB + image = Image.fromarray(pixels, 'RGB') + + # Determine output path + if output_name is None: + output_name = tga_path.stem + + png_path = self.output_dir / f"{output_name}.png" + + # Save as PNG + image.save(png_path, 'PNG') + + logger.info(f"Saved: {png_path}") + return png_path + + except Exception as e: + logger.error(f"Error converting {tga_path}: {e}") + return None + + def convert_cache_folder(self, cache_path: Optional[Path] = None) -> Dict[str, Path]: + """ + Convert all TGA files in cache folder to PNG. + + Args: + cache_path: Path to cache folder (auto-detect if None) + + Returns: + Dictionary mapping TGA filenames to PNG paths + """ + results = {} + + if cache_path is None: + cache_path = self.find_cache_folder() + + if not cache_path or not cache_path.exists(): + logger.error("Cache folder not found") + return results + + # Find all TGA files + tga_files = list(cache_path.glob("*.tga")) + + if not tga_files: + logger.warning(f"No .tga files found in: {cache_path}") + return results + + logger.info(f"Found {len(tga_files)} TGA files to convert") + + # Convert each file + for i, tga_path in enumerate(tga_files, 1): + logger.info(f"[{i}/{len(tga_files)}] Converting: {tga_path.name}") + png_path = self.convert_tga_to_png(tga_path) + if png_path: + results[tga_path.name] = png_path + + logger.info(f"Converted {len(results)}/{len(tga_files)} files successfully") + logger.info(f"Output directory: {self.output_dir}") + + return results + + def get_conversion_summary(self) -> str: + """Get a summary of available TGA files and conversion status.""" + cache_path = self.find_cache_folder() + + if not cache_path: + return "āŒ Cache folder not found" + + tga_files = list(cache_path.glob("*.tga")) + png_files = list(self.output_dir.glob("*.png")) + + lines = [ + "šŸ“ TGA Icon Cache Summary", + "", + f"Cache location: {cache_path}", + f"TGA files found: {len(tga_files)}", + f"PNG files converted: {len(png_files)}", + f"Output directory: {self.output_dir}", + "", + ] + + if tga_files: + lines.append("Sample TGA files:") + for tga in tga_files[:5]: + tga_info = self.read_tga_header(tga) + if tga_info: + lines.append(f" • {tga.name} ({tga_info.width}x{tga_info.height})") + else: + lines.append(f" • {tga.name} (invalid)") + if len(tga_files) > 5: + lines.append(f" ... and {len(tga_files) - 5} more") + + return "\n".join(lines) + + +# Convenience functions +def convert_tga_to_png(tga_path: str, output_dir: Optional[str] = None) -> Optional[str]: + """Quick convert single TGA file.""" + converter = TGAConverter(Path(output_dir) if output_dir else None) + result = converter.convert_tga_to_png(Path(tga_path)) + return str(result) if result else None + + +def convert_all_cache_icons(output_dir: Optional[str] = None) -> List[str]: + """Convert all cached icons.""" + converter = TGAConverter(Path(output_dir) if output_dir else None) + results = converter.convert_cache_folder() + return [str(path) for path in results.values()] + + +def main(): + """CLI interface for TGA conversion.""" + import sys + + print("šŸ”§ Lemontropia Suite - TGA Icon Converter") + print("=" * 50) + + converter = TGAConverter() + + # Show summary + print(converter.get_conversion_summary()) + print() + + # Ask to convert + response = input("\nConvert all TGA files to PNG? (y/n): ") + if response.lower() == 'y': + results = converter.convert_cache_folder() + print(f"\nāœ… Converted {len(results)} files to:") + print(converter.output_dir) + else: + print("Cancelled.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ui/main_window.py b/ui/main_window.py index 54985b8..e58b2e5 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -121,6 +121,7 @@ from ui.session_history import SessionHistoryDialog from ui.gallery_dialog import GalleryDialog, ScreenshotCapture from ui.settings_dialog import SettingsDialog from ui.inventory_scanner_dialog import InventoryScannerDialog +from ui.tga_converter_dialog import TGAConverterDialog # ============================================================================ # Screenshot Hotkey Integration @@ -761,6 +762,12 @@ class MainWindow(QMainWindow): inventory_scan_action.triggered.connect(self.on_inventory_scan) vision_menu.addAction(inventory_scan_action) + # TGA Icon Converter + tga_convert_action = QAction("šŸ”§ &TGA Icon Converter", self) + tga_convert_action.setShortcut("Ctrl+T") + tga_convert_action.triggered.connect(self.on_tga_convert) + vision_menu.addAction(tga_convert_action) + # View menu view_menu = menubar.addMenu("&View") @@ -1913,6 +1920,15 @@ class MainWindow(QMainWindow): self.log_error("Vision", f"Failed to open inventory scanner: {e}") QMessageBox.warning(self, "Error", f"Could not open Inventory Scanner: {e}") + def on_tga_convert(self): + """Open TGA Icon Converter dialog.""" + try: + dialog = TGAConverterDialog(self) + dialog.exec() + except Exception as e: + self.log_error("Vision", f"Failed to open TGA converter: {e}") + QMessageBox.warning(self, "Error", f"Could not open TGA Converter: {e}") + # ======================================================================== # Settings Management # ======================================================================== diff --git a/ui/tga_converter_dialog.py b/ui/tga_converter_dialog.py new file mode 100644 index 0000000..deca786 --- /dev/null +++ b/ui/tga_converter_dialog.py @@ -0,0 +1,374 @@ +""" +Lemontropia Suite - TGA Icon Converter Dialog +UI for converting Entropia Universe cached .tga icons to PNG. +""" + +import logging +from pathlib import Path +from typing import Optional + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QPushButton, QListWidget, QListWidgetItem, + QGroupBox, QProgressBar, QTextEdit, QFileDialog, + QMessageBox, QSpinBox, QCheckBox +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QPixmap + +from modules.tga_converter import TGAConverter + +logger = logging.getLogger(__name__) + + +class TGAConvertWorker(QThread): + """Background worker for TGA conversion.""" + + progress_update = pyqtSignal(str) + file_converted = pyqtSignal(str, str) # tga_name, png_path + conversion_complete = pyqtSignal(int, int) # success_count, total_count + conversion_error = pyqtSignal(str) + + def __init__(self, converter: TGAConverter): + super().__init__() + self.converter = converter + self._is_running = True + + def run(self): + """Run the conversion.""" + try: + # Find cache folder + self.progress_update.emit("Finding cache folder...") + cache_path = self.converter.find_cache_folder() + + if not cache_path: + self.conversion_error.emit("Cache folder not found") + return + + # Get list of TGA files + tga_files = list(cache_path.glob("*.tga")) + total = len(tga_files) + success = 0 + + self.progress_update.emit(f"Found {total} TGA files") + + for i, tga_path in enumerate(tga_files): + if not self._is_running: + break + + self.progress_update.emit(f"[{i+1}/{total}] Converting: {tga_path.name}") + + png_path = self.converter.convert_tga_to_png(tga_path) + if png_path: + success += 1 + self.file_converted.emit(tga_path.name, str(png_path)) + + self.conversion_complete.emit(success, total) + + except Exception as e: + self.conversion_error.emit(str(e)) + + def stop(self): + """Stop the conversion.""" + self._is_running = False + + +class TGAConverterDialog(QDialog): + """ + Dialog for converting Entropia Universe TGA icons to PNG. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("šŸ”§ TGA Icon Converter") + self.setMinimumSize(700, 600) + self.resize(800, 700) + + self.converter = TGAConverter() + self.convert_worker: Optional[TGAConvertWorker] = None + self.converted_files = [] + + self._setup_ui() + self._apply_dark_theme() + + # Auto-scan on open + self._scan_cache() + + def _setup_ui(self): + """Setup the dialog UI.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(15, 15, 15, 15) + layout.setSpacing(10) + + # Header + header = QLabel("šŸ”§ TGA Icon Converter") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #4caf50;") + layout.addWidget(header) + + desc = QLabel( + "Convert Entropia Universe cached .tga icon files to PNG format.\n" + "The game stores item icons in the cache folder as .tga files." + ) + desc.setStyleSheet("color: #888; padding: 5px;") + desc.setWordWrap(True) + layout.addWidget(desc) + + # Cache folder info + cache_group = QGroupBox("šŸ“ Cache Folder") + cache_layout = QVBoxLayout(cache_group) + + self.cache_path_label = QLabel("Searching...") + self.cache_path_label.setStyleSheet("font-family: Consolas; color: #888;") + cache_layout.addWidget(self.cache_path_label) + + cache_buttons = QHBoxLayout() + + self.scan_btn = QPushButton("šŸ” Scan Cache") + self.scan_btn.clicked.connect(self._scan_cache) + cache_buttons.addWidget(self.scan_btn) + + browse_cache_btn = QPushButton("Browse...") + browse_cache_btn.clicked.connect(self._browse_cache_folder) + cache_buttons.addWidget(browse_cache_btn) + + cache_buttons.addStretch() + cache_layout.addLayout(cache_buttons) + + layout.addWidget(cache_group) + + # TGA Files list + files_group = QGroupBox("šŸ“„ TGA Files Found") + 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.setMaximumHeight(150) + files_layout.addWidget(self.files_list) + + layout.addWidget(files_group) + + # Output settings + output_group = QGroupBox("šŸ’¾ Output Settings") + output_layout = QHBoxLayout(output_group) + + output_layout.addWidget(QLabel("Output folder:")) + + self.output_path_label = QLabel(str(self.converter.output_dir)) + self.output_path_label.setStyleSheet("font-family: Consolas; color: #888;") + output_layout.addWidget(self.output_path_label, 1) + + output_browse = QPushButton("Browse...") + output_browse.clicked.connect(self._browse_output_folder) + output_layout.addWidget(output_browse) + + layout.addWidget(output_group) + + # Convert button + self.convert_btn = QPushButton("šŸš€ Convert All to PNG") + self.convert_btn.setMinimumHeight(50) + self.convert_btn.setStyleSheet(""" + QPushButton { + background-color: #0d47a1; + font-weight: bold; + font-size: 14px; + } + QPushButton:hover { background-color: #1565c0; } + QPushButton:disabled { background-color: #333; } + """) + self.convert_btn.clicked.connect(self._start_conversion) + layout.addWidget(self.convert_btn) + + # Progress + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 0) # Indeterminate + self.progress_bar.setVisible(False) + layout.addWidget(self.progress_bar) + + self.status_label = QLabel("Ready") + self.status_label.setStyleSheet("color: #888; padding: 5px;") + layout.addWidget(self.status_label) + + # Results + results_group = QGroupBox("āœ… Converted Files") + results_layout = QVBoxLayout(results_group) + + self.results_list = QListWidget() + results_layout.addWidget(self.results_list) + + results_buttons = QHBoxLayout() + + open_folder_btn = QPushButton("šŸ“‚ Open Output Folder") + open_folder_btn.clicked.connect(self._open_output_folder) + results_buttons.addWidget(open_folder_btn) + + results_buttons.addStretch() + results_layout.addLayout(results_buttons) + + layout.addWidget(results_group) + + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.accept) + layout.addWidget(close_btn) + + def _scan_cache(self): + """Scan for TGA files in cache.""" + self.files_list.clear() + self.files_count_label.setText("Scanning...") + + cache_path = self.converter.find_cache_folder() + + if cache_path: + self.cache_path_label.setText(str(cache_path)) + + tga_files = list(cache_path.glob("*.tga")) + self.files_count_label.setText(f"Found {len(tga_files)} TGA files") + + for tga_file in tga_files: + item = QListWidgetItem(tga_file.name) + # Try to get TGA info + tga_info = self.converter.read_tga_header(tga_file) + if tga_info: + item.setToolTip(f"{tga_info.width}x{tga_info.height}, {tga_info.pixel_depth}bpp") + self.files_list.addItem(item) + + self.convert_btn.setEnabled(len(tga_files) > 0) + else: + self.cache_path_label.setText("āŒ Not found") + self.files_count_label.setText("Cache folder not found") + self.convert_btn.setEnabled(False) + + def _browse_cache_folder(self): + """Browse for cache folder.""" + dir_path = QFileDialog.getExistingDirectory( + self, + "Select Entropia Universe Cache Folder", + str(Path.home() / "Documents" / "Entropia Universe"), + QFileDialog.Option.ShowDirsOnly + ) + + if dir_path: + self.converter._cache_path = Path(dir_path) + self._scan_cache() + + def _browse_output_folder(self): + """Browse for output folder.""" + dir_path = QFileDialog.getExistingDirectory( + self, + "Select Output Folder", + str(self.converter.output_dir), + QFileDialog.Option.ShowDirsOnly + ) + + if dir_path: + self.converter.output_dir = Path(dir_path) + self.output_path_label.setText(dir_path) + + def _start_conversion(self): + """Start TGA to PNG conversion.""" + if self.convert_worker and self.convert_worker.isRunning(): + QMessageBox.warning(self, "Busy", "Conversion already in progress") + return + + self.convert_btn.setEnabled(False) + self.progress_bar.setVisible(True) + self.results_list.clear() + self.converted_files = [] + + # Start worker + self.convert_worker = TGAConvertWorker(self.converter) + self.convert_worker.progress_update.connect(self._on_progress) + self.convert_worker.file_converted.connect(self._on_file_converted) + self.convert_worker.conversion_complete.connect(self._on_conversion_complete) + self.convert_worker.conversion_error.connect(self._on_conversion_error) + self.convert_worker.start() + + def _on_progress(self, message: str): + """Handle progress update.""" + self.status_label.setText(message) + + def _on_file_converted(self, tga_name: str, png_path: str): + """Handle file converted.""" + self.converted_files.append(png_path) + item = QListWidgetItem(f"āœ… {tga_name} → {Path(png_path).name}") + self.results_list.addItem(item) + + def _on_conversion_complete(self, success: int, total: int): + """Handle conversion complete.""" + self.progress_bar.setVisible(False) + self.convert_btn.setEnabled(True) + self.status_label.setText(f"Complete: {success}/{total} files converted") + + QMessageBox.information( + self, + "Conversion Complete", + f"Successfully converted {success} of {total} files\n\n" + f"Output: {self.converter.output_dir}" + ) + + def _on_conversion_error(self, error: str): + """Handle conversion error.""" + self.progress_bar.setVisible(False) + self.convert_btn.setEnabled(True) + self.status_label.setText(f"Error: {error}") + + QMessageBox.critical(self, "Error", error) + + def _open_output_folder(self): + """Open output folder in file explorer.""" + import os + import platform + + output_path = str(self.converter.output_dir) + + if platform.system() == "Windows": + os.startfile(output_path) + elif platform.system() == "Darwin": # macOS + import subprocess + subprocess.run(["open", output_path]) + else: # Linux + import subprocess + subprocess.run(["xdg-open", output_path]) + + def closeEvent(self, event): + """Handle dialog close.""" + if self.convert_worker and self.convert_worker.isRunning(): + self.convert_worker.stop() + self.convert_worker.wait(1000) + event.accept() + + def _apply_dark_theme(self): + """Apply dark theme.""" + self.setStyleSheet(""" + QDialog { + background-color: #1e1e1e; + color: #e0e0e0; + } + QGroupBox { + font-weight: bold; + border: 1px solid #444; + border-radius: 6px; + margin-top: 10px; + padding-top: 10px; + } + QListWidget { + background-color: #252525; + border: 1px solid #444; + color: #e0e0e0; + } + QPushButton { + background-color: #0d47a1; + border: 1px solid #1565c0; + border-radius: 4px; + padding: 6px 12px; + color: white; + } + QPushButton:hover { + background-color: #1565c0; + } + QLabel { + color: #e0e0e0; + } + """) \ No newline at end of file