392 lines
13 KiB
Python
392 lines
13 KiB
Python
"""
|
|
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('<H', header[8:10])[0]
|
|
y_origin = struct.unpack('<H', header[10:12])[0]
|
|
width = struct.unpack('<H', header[12:14])[0]
|
|
height = struct.unpack('<H', header[14:16])[0]
|
|
pixel_depth = header[16]
|
|
image_descriptor = header[17]
|
|
|
|
# Check if valid TGA
|
|
if width == 0 or height == 0:
|
|
logger.warning(f"Invalid TGA dimensions: {filepath}")
|
|
return None
|
|
|
|
has_alpha = (pixel_depth == 32) or (image_descriptor & 0x0F > 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() |