fix: TGA converter improvements
1. Auto-detection now uses rglob to find TGA files in ALL subfolders - Properly finds icons in version subfolders like 19.3.2.201024 - Shows relative paths in the file list 2. Default settings changed: - Default canvas: 320x320 (index 5) - Default upscale: No Upscaling (first in list) 3. Added iconcache.dat parser module (icon_cache_parser.py) - Attempts to extract item names from binary cache file - Uses heuristics to find icon ID to name mappings
This commit is contained in:
parent
5ec7541e78
commit
8f7c7cb5a1
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""
|
||||||
|
Lemontropia Suite - Icon Cache Parser
|
||||||
|
Parse Entropia Universe iconcache.dat for item metadata.
|
||||||
|
|
||||||
|
The iconcache.dat file contains mappings between icon IDs and item information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, List, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IconCacheEntry:
|
||||||
|
"""Represents an entry in the icon cache."""
|
||||||
|
icon_id: str # e.g., "i0000328"
|
||||||
|
item_name: str
|
||||||
|
item_type: str = "" # weapon, armor, etc.
|
||||||
|
extra_data: bytes = b""
|
||||||
|
|
||||||
|
|
||||||
|
class IconCacheParser:
|
||||||
|
"""
|
||||||
|
Parser for Entropia Universe iconcache.dat files.
|
||||||
|
|
||||||
|
The iconcache.dat is a binary file that maps icon IDs to item names.
|
||||||
|
Format appears to be proprietary/binary.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
parser = IconCacheParser()
|
||||||
|
entries = parser.parse_cache_file("path/to/iconcache.dat")
|
||||||
|
|
||||||
|
# Look up an item name by icon ID
|
||||||
|
name = parser.get_item_name("i0000328")
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Common cache file locations
|
||||||
|
DEFAULT_CACHE_PATHS = [
|
||||||
|
Path("C:") / "ProgramData" / "Entropia Universe" / "public_users_data" / "cache" / "icon",
|
||||||
|
Path.home() / "Documents" / "Entropia Universe" / "cache" / "icons",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, cache_path: Optional[Path] = None):
|
||||||
|
"""
|
||||||
|
Initialize parser.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_path: Path to cache directory (auto-find if None)
|
||||||
|
"""
|
||||||
|
self.cache_path = cache_path
|
||||||
|
self._entries: Dict[str, IconCacheEntry] = {}
|
||||||
|
self._parsed = False
|
||||||
|
|
||||||
|
def find_cache_file(self) -> Optional[Path]:
|
||||||
|
"""Find the iconcache.dat file."""
|
||||||
|
search_paths = []
|
||||||
|
|
||||||
|
if self.cache_path:
|
||||||
|
search_paths.append(self.cache_path)
|
||||||
|
else:
|
||||||
|
search_paths = self.DEFAULT_CACHE_PATHS
|
||||||
|
|
||||||
|
for path in search_paths:
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look for iconcache.dat in this folder or subfolders
|
||||||
|
for dat_file in path.rglob("iconcache.dat"):
|
||||||
|
logger.info(f"Found iconcache.dat: {dat_file}")
|
||||||
|
return dat_file
|
||||||
|
|
||||||
|
logger.warning("iconcache.dat not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_cache_file(self, filepath: Optional[Path] = None) -> Dict[str, IconCacheEntry]:
|
||||||
|
"""
|
||||||
|
Parse the iconcache.dat file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to iconcache.dat (auto-find if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping icon_id to IconCacheEntry
|
||||||
|
"""
|
||||||
|
if filepath is None:
|
||||||
|
filepath = self.find_cache_file()
|
||||||
|
|
||||||
|
if not filepath or not filepath.exists():
|
||||||
|
logger.error("iconcache.dat not found")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
entries = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
logger.info(f"Parsing iconcache.dat ({len(data)} bytes)")
|
||||||
|
|
||||||
|
# Try to identify format
|
||||||
|
# Common binary patterns to look for:
|
||||||
|
# - Magic headers
|
||||||
|
# - String tables
|
||||||
|
# - Index structures
|
||||||
|
|
||||||
|
# Try simple string extraction first (many .dat files have embedded strings)
|
||||||
|
entries = self._extract_strings(data)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
# Try more sophisticated parsing
|
||||||
|
entries = self._parse_binary_structure(data)
|
||||||
|
|
||||||
|
self._entries = entries
|
||||||
|
self._parsed = True
|
||||||
|
|
||||||
|
logger.info(f"Parsed {len(entries)} entries from iconcache.dat")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to parse iconcache.dat: {e}")
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def _extract_strings(self, data: bytes) -> Dict[str, IconCacheEntry]:
|
||||||
|
"""
|
||||||
|
Extract readable strings from binary data.
|
||||||
|
|
||||||
|
This is a heuristic approach that looks for:
|
||||||
|
- Icon IDs (pattern: i0000000)
|
||||||
|
- Item names (readable ASCII/UTF-8 strings)
|
||||||
|
"""
|
||||||
|
entries = {}
|
||||||
|
|
||||||
|
# Look for icon ID patterns (i followed by digits)
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Find all icon IDs
|
||||||
|
icon_pattern = rb'i\d{7}' # i0000000 format
|
||||||
|
icon_ids = re.findall(icon_pattern, data)
|
||||||
|
|
||||||
|
logger.debug(f"Found {len(icon_ids)} potential icon IDs")
|
||||||
|
|
||||||
|
# Look for strings that might be item names
|
||||||
|
# Item names are typically printable ASCII
|
||||||
|
string_pattern = rb'[A-Za-z][A-Za-z0-9_\s\-\(\)]{3,50}(?=[\x00\x01\x02])'
|
||||||
|
strings = re.findall(string_pattern, data)
|
||||||
|
|
||||||
|
logger.debug(f"Found {len(strings)} potential strings")
|
||||||
|
|
||||||
|
# Try to match icon IDs with nearby strings
|
||||||
|
for i, icon_id_bytes in enumerate(icon_ids[:50]): # Limit to first 50 for testing
|
||||||
|
icon_id = icon_id_bytes.decode('ascii', errors='ignore')
|
||||||
|
|
||||||
|
# Find position in data
|
||||||
|
pos = data.find(icon_id_bytes)
|
||||||
|
if pos == -1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look for nearby strings (within 200 bytes)
|
||||||
|
search_start = max(0, pos - 100)
|
||||||
|
search_end = min(len(data), pos + 200)
|
||||||
|
nearby_data = data[search_start:search_end]
|
||||||
|
|
||||||
|
# Find strings in this region
|
||||||
|
nearby_strings = re.findall(string_pattern, nearby_data)
|
||||||
|
|
||||||
|
if nearby_strings:
|
||||||
|
# Use the longest string as the item name (heuristic)
|
||||||
|
item_name = max(nearby_strings, key=len).decode('latin-1', errors='ignore')
|
||||||
|
entries[icon_id] = IconCacheEntry(
|
||||||
|
icon_id=icon_id,
|
||||||
|
item_name=item_name.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def _parse_binary_structure(self, data: bytes) -> Dict[str, IconCacheEntry]:
|
||||||
|
"""
|
||||||
|
Try to parse the binary structure of the file.
|
||||||
|
|
||||||
|
This attempts to identify record structures based on:
|
||||||
|
- Fixed-size records
|
||||||
|
- Offset tables
|
||||||
|
- Common binary patterns
|
||||||
|
"""
|
||||||
|
entries = {}
|
||||||
|
|
||||||
|
if len(data) < 100:
|
||||||
|
return entries
|
||||||
|
|
||||||
|
# Try to detect record size by looking for repeating patterns
|
||||||
|
# Many binary cache files have fixed-size records
|
||||||
|
|
||||||
|
# Check header
|
||||||
|
magic = data[:4]
|
||||||
|
logger.debug(f"File header (first 4 bytes): {magic.hex()}")
|
||||||
|
|
||||||
|
# Try reading as simple table
|
||||||
|
# Some formats: [count][record_size][records...]
|
||||||
|
try:
|
||||||
|
count = struct.unpack('<I', data[:4])[0] # Little-endian uint32
|
||||||
|
if 0 < count < 100000: # Reasonable count
|
||||||
|
logger.debug(f"Potential record count: {count}")
|
||||||
|
|
||||||
|
# Try to find record size
|
||||||
|
record_size = (len(data) - 8) // count if count > 0 else 0
|
||||||
|
logger.debug(f"Estimated record size: {record_size}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def get_item_name(self, icon_id: str) -> Optional[str]:
|
||||||
|
"""Get item name by icon ID."""
|
||||||
|
if not self._parsed:
|
||||||
|
self.parse_cache_file()
|
||||||
|
|
||||||
|
entry = self._entries.get(icon_id)
|
||||||
|
return entry.item_name if entry else None
|
||||||
|
|
||||||
|
def get_all_entries(self) -> Dict[str, IconCacheEntry]:
|
||||||
|
"""Get all parsed entries."""
|
||||||
|
if not self._parsed:
|
||||||
|
self.parse_cache_file()
|
||||||
|
return self._entries.copy()
|
||||||
|
|
||||||
|
def print_summary(self):
|
||||||
|
"""Print a summary of the parsed cache."""
|
||||||
|
if not self._parsed:
|
||||||
|
self.parse_cache_file()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Icon Cache Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Total entries: {len(self._entries)}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Show sample entries
|
||||||
|
if self._entries:
|
||||||
|
print("Sample entries:")
|
||||||
|
for i, (icon_id, entry) in enumerate(list(self._entries.items())[:10]):
|
||||||
|
print(f" {icon_id}: {entry.item_name}")
|
||||||
|
else:
|
||||||
|
print("No entries parsed (format may be unknown)")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience function
|
||||||
|
def parse_icon_cache(cache_path: Optional[Path] = None) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Quick function to parse icon cache and return icon_id -> name mapping.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping icon_id to item_name
|
||||||
|
"""
|
||||||
|
parser = IconCacheParser(cache_path)
|
||||||
|
entries = parser.parse_cache_file()
|
||||||
|
return {k: v.item_name for k, v in entries.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""CLI to analyze iconcache.dat."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print("🔍 Entropia Universe Icon Cache Parser")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
parser = IconCacheParser()
|
||||||
|
|
||||||
|
# Try to find cache file
|
||||||
|
cache_file = parser.find_cache_file()
|
||||||
|
|
||||||
|
if cache_file:
|
||||||
|
print(f"Found: {cache_file}")
|
||||||
|
print()
|
||||||
|
parser.parse_cache_file(cache_file)
|
||||||
|
parser.print_summary()
|
||||||
|
else:
|
||||||
|
print("❌ iconcache.dat not found")
|
||||||
|
print()
|
||||||
|
print("Searched in:")
|
||||||
|
for path in parser.DEFAULT_CACHE_PATHS:
|
||||||
|
print(f" - {path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -247,7 +247,7 @@ class TGAConverterDialog(QDialog):
|
||||||
self.canvas_combo.addItem("280x280", (280, 280))
|
self.canvas_combo.addItem("280x280", (280, 280))
|
||||||
self.canvas_combo.addItem("320x320", (320, 320))
|
self.canvas_combo.addItem("320x320", (320, 320))
|
||||||
self.canvas_combo.addItem("512x512", (512, 512))
|
self.canvas_combo.addItem("512x512", (512, 512))
|
||||||
self.canvas_combo.setCurrentIndex(4) # Default to 320x320
|
self.canvas_combo.setCurrentIndex(5) # Default to 320x320
|
||||||
canvas_layout.addWidget(self.canvas_combo)
|
canvas_layout.addWidget(self.canvas_combo)
|
||||||
|
|
||||||
canvas_layout.addSpacing(20)
|
canvas_layout.addSpacing(20)
|
||||||
|
|
@ -337,11 +337,20 @@ class TGAConverterDialog(QDialog):
|
||||||
if cache_path:
|
if cache_path:
|
||||||
self.cache_path_label.setText(str(cache_path))
|
self.cache_path_label.setText(str(cache_path))
|
||||||
|
|
||||||
tga_files = list(cache_path.glob("*.tga"))
|
# Use rglob to search ALL subfolders recursively
|
||||||
|
tga_files = list(cache_path.rglob("*.tga"))
|
||||||
self.files_count_label.setText(f"Found {len(tga_files)} TGA files")
|
self.files_count_label.setText(f"Found {len(tga_files)} TGA files")
|
||||||
|
|
||||||
for tga_file in tga_files:
|
for tga_file in tga_files:
|
||||||
item = QListWidgetItem(tga_file.name)
|
# Show relative path in list
|
||||||
|
try:
|
||||||
|
display_name = str(tga_file.relative_to(cache_path))
|
||||||
|
except:
|
||||||
|
display_name = tga_file.name
|
||||||
|
|
||||||
|
item = QListWidgetItem(display_name)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, str(tga_file))
|
||||||
|
|
||||||
# Try to get TGA info
|
# Try to get TGA info
|
||||||
tga_info = self.converter.read_tga_header(tga_file)
|
tga_info = self.converter.read_tga_header(tga_file)
|
||||||
if tga_info:
|
if tga_info:
|
||||||
|
|
@ -353,6 +362,7 @@ class TGAConverterDialog(QDialog):
|
||||||
self.cache_path_label.setText("❌ Not found")
|
self.cache_path_label.setText("❌ Not found")
|
||||||
self.files_count_label.setText("Cache folder not found")
|
self.files_count_label.setText("Cache folder not found")
|
||||||
self.convert_btn.setEnabled(False)
|
self.convert_btn.setEnabled(False)
|
||||||
|
self.convert_btn.setEnabled(False)
|
||||||
|
|
||||||
def _browse_cache_folder(self):
|
def _browse_cache_folder(self):
|
||||||
"""Browse for cache folder."""
|
"""Browse for cache folder."""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue