Lemontropia-Suite/standalone_icon_extractor.py

784 lines
27 KiB
Python

"""
Entropia Universe Icon Extractor
A standalone tool for extracting game icons from cache.
Description: Extracts item icons from Entropia Universe game cache and converts
them to PNG format for use with EntropiaNexus.com wiki submissions.
Important: Items must be seen/rendered in-game before they appear in the cache.
Usage:
python standalone_icon_extractor.py
Output Location:
Icons are saved to your Documents/Entropia Universe/Icons/ folder
(same location where chat.log is normally stored)
Developer: ImpulsiveFPS
Discord: impulsivefps
Website: https://EntropiaNexus.com
Disclaimer:
This tool is not affiliated with, endorsed by, or connected to
Entropia Universe or Mindark PE AB in any way.
Entropia Universe is a registered trademark of Mindark PE AB.
"""
import sys
import logging
from pathlib import Path
from typing import Optional, List, Tuple
import ctypes
try:
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QComboBox, QListWidget, QListWidgetItem,
QFileDialog, QProgressBar, QGroupBox, QMessageBox, QCheckBox,
QSplitter, QTextEdit
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSettings
from PyQt6.QtGui import QIcon, QPixmap, QFont
PYQT_AVAILABLE = True
except ImportError:
PYQT_AVAILABLE = False
print("PyQt6 not available. Install with: pip install PyQt6")
sys.exit(1)
try:
from PIL import Image, ImageFilter
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
print("Pillow not available. Install with: pip install Pillow")
sys.exit(1)
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Application metadata
APP_NAME = "Entropia Universe Icon Extractor"
APP_VERSION = "1.0.0"
DEVELOPER = "ImpulsiveFPS"
DISCORD = "impulsivefps"
WEBSITE = "https://EntropiaNexus.com"
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 320x320 canvas."""
CANVAS_SIZE = (320, 320) # Hardcoded to 320x320
def __init__(self, output_dir: Optional[Path] = None):
# Default to user's Documents/Entropia Universe/Icons/
# This works on any Windows username
if output_dir is None:
self.output_dir = Path.home() / "Documents" / "Entropia Universe" / "Icons"
else:
self.output_dir = output_dir
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."""
# Hardcoded path - works on any system with EU installed
cache_path = Path("C:/ProgramData/Entropia Universe/public_users_data/cache/icon")
if cache_path.exists():
self._cache_path = cache_path
return cache_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) -> Optional[Path]:
"""
Convert a TGA file to PNG with 320x320 canvas.
Args:
tga_path: Path to TGA file
output_name: Optional custom output name
Returns:
Path to output PNG file or None if failed
"""
try:
# Load TGA
image = Image.open(tga_path)
if image.mode != 'RGBA':
image = image.convert('RGBA')
# Apply 320x320 canvas (centered, no upscaling)
image = self._apply_canvas(image)
# 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) -> Image.Image:
"""
Place image centered on a 320x320 canvas.
No upscaling - original size centered on canvas.
"""
canvas_w, canvas_h = self.CANVAS_SIZE
img_w, img_h = image.size
# Create transparent canvas
canvas = Image.new('RGBA', self.CANVAS_SIZE, (0, 0, 0, 0))
# Center on canvas (no scaling)
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):
super().__init__()
self.files = files
self.converter = converter
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)
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(APP_NAME)
self.setMinimumSize(950, 750)
self.converter = TGAConverter()
self.worker: Optional[ConversionWorker] = None
self.found_files: List[Path] = []
# Hardcoded base cache path
self.base_cache_path = Path("C:/ProgramData/Entropia Universe/public_users_data/cache/icon")
self.settings = QSettings("ImpulsiveFPS", "EUIconExtractor")
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(APP_NAME)
header.setStyleSheet("font-size: 22px; font-weight: bold; color: #4caf50;")
layout.addWidget(header)
# Description
desc = QLabel(
"Extract item icons from Entropia Universe game cache and convert them to PNG format.\n"
"These icons can be submitted to EntropiaNexus.com to help complete the item database."
)
desc.setStyleSheet("color: #aaaaaa; padding: 5px;")
desc.setWordWrap(True)
layout.addWidget(desc)
# Important notice
notice_group = QGroupBox("Important Information")
notice_layout = QVBoxLayout(notice_group)
notice_text = QTextEdit()
notice_text.setReadOnly(True)
notice_text.setMaximumHeight(120)
notice_text.setStyleSheet("""
QTextEdit {
background-color: #2a2520;
color: #ffcc80;
border: 1px solid #5d4037;
border-radius: 3px;
font-size: 12px;
}
""")
notice_text.setText(
"REQUIREMENT: Items must be seen/rendered in-game before they appear in the cache!\n"
"If an item icon is missing, you need to view the item in your inventory or see it dropped as loot first.\n\n"
f"Output Location: Icons are saved to your Documents folder ({self.converter.output_dir})\n"
"in the same location where Entropia Universe normally stores your chat.log file."
)
notice_layout.addWidget(notice_text)
layout.addWidget(notice_group)
# 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 Source")
cache_layout = QVBoxLayout(cache_group)
# Base path (hardcoded)
base_label = QLabel("Game Cache Location:")
cache_layout.addWidget(base_label)
self.cache_label = QLabel(str(self.base_cache_path))
self.cache_label.setStyleSheet("font-family: Consolas; font-size: 11px; 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 version folders")
self.all_subfolders_check.setToolTip("Merge icons from all game versions")
self.all_subfolders_check.stateChanged.connect(self._on_all_subfolders_changed)
cache_layout.addWidget(self.all_subfolders_check)
left_layout.addWidget(cache_group)
# Output folder
output_group = QGroupBox("Output Location")
output_layout = QVBoxLayout(output_group)
output_info = QLabel(
"Icons will be saved to your Documents folder:\n"
"(Same location as chat.log)"
)
output_info.setStyleSheet("color: #888; font-size: 11px;")
output_layout.addWidget(output_info)
self.output_label = QLabel(str(self.converter.output_dir))
self.output_label.setStyleSheet("font-family: Consolas; font-size: 11px; color: #888; padding: 5px; background: #1a1a1a; border-radius: 3px;")
self.output_label.setWordWrap(True)
output_layout.addWidget(self.output_label)
change_btn = QPushButton("Change Output Folder...")
change_btn.clicked.connect(self._browse_output)
output_layout.addWidget(change_btn)
left_layout.addWidget(output_group)
# Settings (simplified - just 320x320)
settings_group = QGroupBox("Export Settings")
settings_layout = QVBoxLayout(settings_group)
settings_info = QLabel(
"Export Format: PNG with transparent background\n"
"Canvas Size: 320x320 pixels (centered)\n"
"Upscaling: None (original icon size)"
)
settings_info.setStyleSheet("color: #888; font-size: 11px;")
settings_layout.addWidget(settings_info)
left_layout.addWidget(settings_group)
# Nexus link
nexus_group = QGroupBox("EntropiaNexus.com")
nexus_layout = QVBoxLayout(nexus_group)
nexus_info = QLabel(
"Submit extracted icons to help complete the\n"
"Entropia Universe item database and wiki!"
)
nexus_info.setStyleSheet("color: #4caf50;")
nexus_layout.addWidget(nexus_info)
nexus_btn = QPushButton("Open EntropiaNexus.com")
nexus_btn.clicked.connect(lambda: self._open_url(WEBSITE))
nexus_layout.addWidget(nexus_btn)
left_layout.addWidget(nexus_group)
# Convert button
self.convert_btn = QPushButton("Start Extracting 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("Available Icons")
files_layout = QVBoxLayout(files_group)
files_info = QLabel("Select icons to extract (or leave unselected to extract all)")
files_info.setStyleSheet("color: #888; font-size: 11px;")
files_layout.addWidget(files_info)
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([380, 520])
layout.addWidget(splitter, 1)
# Footer
footer = QLabel(
f"Developed by {DEVELOPER} | Discord: {DISCORD} | {WEBSITE}\n"
"Not affiliated with or endorsed by Entropia Universe or Mindark PE AB."
" Entropia Universe is a trademark of Mindark PE AB."
)
footer.setStyleSheet("color: #555; font-size: 10px; padding: 5px;")
footer.setAlignment(Qt.AlignmentFlag.AlignCenter)
footer.setWordWrap(True)
layout.addWidget(footer)
def _open_url(self, url: str):
"""Open URL in default browser."""
import webbrowser
webbrowser.open(url)
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)
def _save_settings(self):
"""Save current settings."""
self.settings.setValue("output_dir", str(self.converter.output_dir))
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 - is Entropia Universe installed?")
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 icons 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 _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 _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 _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 extraction.")
return
# Save settings
self._save_settings()
# Setup UI
self.convert_btn.setEnabled(False)
self.convert_btn.setText("Extracting...")
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)
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"Extracted: {filename} -> {output_path}")
def _on_finished(self, success: int, total: int):
self.convert_btn.setEnabled(True)
self.convert_btn.setText("Start Extracting Icons")
self.progress_bar.setVisible(False)
self.status_label.setText(f"Extracted {success}/{total} icons")
QMessageBox.information(
self,
"Extraction Complete",
f"Successfully extracted {success} of {total} icons.\n\n"
f"Output location:\n{self.converter.output_dir}\n\n"
f"Submit these icons to EntropiaNexus.com to help the community!"
)
def _on_error(self, error_msg: str):
self.convert_btn.setEnabled(True)
self.convert_btn.setText("Start Extracting Icons")
self.progress_bar.setVisible(False)
self.status_label.setText(f"Error: {error_msg}")
QMessageBox.critical(self, "Error", f"Extraction 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 set_app_icon(app: QApplication):
"""Set application icon."""
# Try to load icon from various locations
icon_paths = [
Path(__file__).parent / "assets" / "icon.ico",
Path(__file__).parent / "assets" / "icon.png",
Path(__file__).parent / "icon.ico",
Path(__file__).parent / "icon.png",
]
for icon_path in icon_paths:
if icon_path.exists():
app.setWindowIcon(QIcon(str(icon_path)))
return
# If no icon file, we can't set one programmatically
# User would need to provide an icon file
def main():
"""Main entry point."""
app = QApplication(sys.argv)
app.setStyle('Fusion')
# Set application info for proper Windows taskbar icon
app.setApplicationName(APP_NAME)
app.setApplicationVersion(APP_VERSION)
app.setOrganizationName("ImpulsiveFPS")
# Try to set icon
set_app_icon(app)
# 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;
}
QTextEdit {
background-color: #222;
border: 1px solid #444;
}
""")
window = IconExtractorWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()