""" 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" / "public_users_data" / "cache" / "icon", Path("C:") / "ProgramData" / "Entropia Universe" / "cache" / "icons", Path("C:") / "ProgramData" / "Entropia Universe" / "cache", 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 not path.exists(): continue # Check if this folder directly contains .tga files if list(path.glob("*.tga")): logger.info(f"Found cache folder: {path}") self._cache_path = path return path # Check for subfolders (version folders like "19.3.2.201024") for subfolder in path.iterdir(): if not subfolder.is_dir(): continue # Check if subfolder contains .tga files if list(subfolder.glob("*.tga")): logger.info(f"Found cache subfolder: {subfolder}") self._cache_path = subfolder return subfolder # Check one level deeper (in case of nested structure) for nested in subfolder.iterdir(): if nested.is_dir() and list(nested.glob("*.tga")): logger.info(f"Found cache nested folder: {nested}") self._cache_path = nested return nested # 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 # Also search in ProgramData program_data = Path("C:") / "ProgramData" / "Entropia Universe" if program_data.exists(): for tga_file in program_data.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: # Read header to get ID length header = f.read(18) if len(header) < 18: logger.warning(f"Invalid TGA header: {tga_file.filepath}") return None id_length = header[0] color_map_type = header[1] image_type = header[2] # Skip ID field if present if id_length > 0: f.seek(18 + id_length) # Skip color map if present if color_map_type == 1: # Read color map spec color_map_offset = 3 # After id_length, color_map_type, image_type color_map_length = struct.unpack('> 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, canvas_size: Optional[Tuple[int, int]] = None, upscale: bool = False) -> Optional[Path]: """ Convert a TGA file to PNG with optional canvas sizing. Args: tga_path: Path to .tga file output_name: Optional output filename (without extension) canvas_size: Optional (width, height) for output canvas upscale: Whether to upscale small icons to fit canvas better 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: # Try PIL first (handles more TGA formats) try: image = Image.open(tga_path) # Convert to RGBA if necessary if image.mode in ('RGB', 'RGBA'): # Image is already in good format pass elif image.mode == 'P': # Palette image = image.convert('RGBA') else: image = image.convert('RGBA') # Apply canvas sizing if requested if canvas_size: image = self._apply_canvas(image, canvas_size, upscale) # Determine output path and format if output_name is None: output_name = tga_path.stem # Save as PNG output_path = self.output_dir / f"{output_name}.png" image.save(output_path, 'PNG') logger.info(f"Converted (PIL): {tga_path.name} -> {output_path.name}") return output_path except Exception as pil_error: logger.debug(f"PIL failed, trying manual: {pil_error}") # Fallback to manual TGA reading 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: image = Image.fromarray(pixels, 'RGBA') else: image = Image.fromarray(pixels, 'RGB') # Apply canvas sizing if requested if canvas_size: image = self._apply_canvas(image, canvas_size, upscale) # Determine output path and format if output_name is None: output_name = tga_path.stem # Save as PNG output_path = self.output_dir / f"{output_name}.png" image.save(output_path, 'PNG') logger.info(f"Converted (manual): {tga_path.name} -> {output_path.name}") return output_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, canvas_size: Optional[Tuple[int, int]] = None, upscale: bool = False) -> Dict[str, Path]: """ Convert all TGA files in cache folder to PNG. Args: cache_path: Path to cache folder (auto-detect if None) canvas_size: Optional (width, height) for output canvas upscale: Whether to upscale small icons 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 (including in subfolders) tga_files = list(cache_path.rglob("*.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, canvas_size=canvas_size, upscale=upscale) 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 _apply_canvas(self, image, canvas_size, upscale=False): """ Place image centered on a canvas of specified size. Args: image: Source PIL Image canvas_size: (width, height) for output canvas upscale: Whether to upscale small images to fit canvas better Returns: New image with canvas size, source image centered """ from PIL import Image 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: # Scale up to fit within canvas with some padding (e.g., 90%) max_size = int(min(canvas_w, canvas_h) * 0.9) scale = min(max_size / img_w, max_size / img_h) if scale > 1: # Only upscale, never downscale new_w = int(img_w * scale) new_h = int(img_h * scale) image = image.resize((new_w, new_h), Image.Resampling.LANCZOS) img_w, img_h = new_w, new_h # Calculate centered position x = (canvas_w - img_w) // 2 y = (canvas_h - img_h) // 2 # Paste image onto canvas if image.mode == 'RGBA': canvas.paste(image, (x, y), image) else: canvas.paste(image, (x, y)) return canvas 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()