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("320x320", (320, 320))
|
||||
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.addSpacing(20)
|
||||
|
|
@ -337,11 +337,20 @@ class TGAConverterDialog(QDialog):
|
|||
if 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")
|
||||
|
||||
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
|
||||
tga_info = self.converter.read_tga_header(tga_file)
|
||||
if tga_info:
|
||||
|
|
@ -353,6 +362,7 @@ class TGAConverterDialog(QDialog):
|
|||
self.cache_path_label.setText("❌ Not found")
|
||||
self.files_count_label.setText("Cache folder not found")
|
||||
self.convert_btn.setEnabled(False)
|
||||
self.convert_btn.setEnabled(False)
|
||||
|
||||
def _browse_cache_folder(self):
|
||||
"""Browse for cache folder."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue