Files
yusufipk c2a5a2d4d2 Hearthstone deck tracker for Linux, first public version
Reads the game's own Power.log: no screen capture, no image
recognition, no memory reading.

- deck tracking (remaining cards, draw chance, played cards fade in place)
- opponent panel (revealed cards, hand and deck counts)
- decks read from the game's offline cache, matched to the match
- card art in rows, full card on hover
- overlay and window modes, adjustable transparency, tray icon
- English and Turkish interface
- consistency tests over a real log corpus, cross-checked against Zone.log
2026-07-27 22:31:13 +07:00

162 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Hearthstone kurulumunu, log dizinini ve log.config durumunu bulur.
Oyun Wine/Proton prefix'i içinde çalıştığı için yol sabit değil. Önce
yapılandırma dosyasına, sonra bilinen prefix konumlarına bakılır.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
LOG_DIR_RE = re.compile(r"^Hearthstone_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}$")
# Gerekli log bileşenleri. Power için Verbose=1 şart, yoksa oyun durumu eksik gelir.
REQUIRED_COMPONENTS = {
"Power": {"LogLevel": "1", "FilePrinting": "true", "Verbose": "1"},
"Zone": {"LogLevel": "1", "FilePrinting": "true"},
"LoadingScreen": {"LogLevel": "1", "FilePrinting": "true"},
"Arena": {"LogLevel": "1", "FilePrinting": "true"},
}
# Prefix içindeki oyun dizini, kullanıcıya göre değişen kısımlar glob ile.
PREFIX_GLOBS = (
"~/Games/*/drive_c/Program Files (x86)/Hearthstone",
"~/Games/*/*/drive_c/Program Files (x86)/Hearthstone",
"~/.wine/drive_c/Program Files (x86)/Hearthstone",
"~/.local/share/lutris/prefixes/*/drive_c/Program Files (x86)/Hearthstone",
"~/.steam/steam/steamapps/compatdata/*/pfx/drive_c/Program Files (x86)/Hearthstone",
)
CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "deste" / "config.json"
@dataclass(slots=True)
class Installation:
game_dir: Path
logs_dir: Path
log_config: Path | None
client_config: Path
def _expand(pattern: str) -> list[Path]:
pattern = os.path.expanduser(pattern)
root = Path("/")
parts = Path(pattern).parts[1:]
try:
return sorted(root.glob(str(Path(*parts))))
except (OSError, ValueError):
return []
def find_game_dir(configured: str | None = None) -> Path | None:
"""Hearthstone kurulum dizinini bulur."""
if configured:
path = Path(configured).expanduser()
if (path / "Hearthstone.exe").exists():
return path
for pattern in PREFIX_GLOBS:
for candidate in _expand(pattern):
if (candidate / "Hearthstone.exe").exists():
return candidate
return None
def find_log_config(game_dir: Path) -> Path | None:
"""log.config dosyasını prefix içindeki kullanıcı dizininde arar."""
# .../drive_c/Program Files (x86)/Hearthstone -> .../drive_c
drive_c = game_dir.parent.parent
users = drive_c / "users"
if not users.is_dir():
return None
for user_dir in users.iterdir():
candidate = user_dir / "AppData" / "Local" / "Blizzard" / "Hearthstone" / "log.config"
if candidate.parent.is_dir():
return candidate
return None
def read_config() -> dict:
if CONFIG_PATH.exists():
try:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return {}
def detect(configured_dir: str | None = None) -> Installation | None:
if configured_dir is None:
configured_dir = read_config().get("game_dir")
game_dir = find_game_dir(configured_dir)
if game_dir is None:
return None
return Installation(
game_dir=game_dir,
logs_dir=game_dir / "Logs",
log_config=find_log_config(game_dir),
client_config=game_dir / "client.config",
)
def latest_log_dir(logs_dir: Path) -> Path | None:
"""En son oturumun log dizini. Oyun her açılışta yenisini oluşturur."""
if not logs_dir.is_dir():
return None
candidates = [d for d in logs_dir.iterdir() if d.is_dir() and LOG_DIR_RE.match(d.name)]
if not candidates:
return None
return max(candidates, key=lambda d: d.stat().st_mtime)
def parse_log_config(path: Path) -> dict[str, dict[str, str]]:
sections: dict[str, dict[str, str]] = {}
current: dict[str, str] | None = None
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return sections
for line in text.splitlines():
line = line.strip()
if line.startswith("[") and line.endswith("]"):
current = {}
sections[line[1:-1]] = current
elif "=" in line and current is not None:
key, _, value = line.partition("=")
current[key.strip()] = value.strip()
return sections
def check_log_config(path: Path | None) -> list[str]:
"""Eksik veya hatalı log.config ayarlarını insan okur biçimde döner."""
if path is None or not path.exists():
return ["log.config bulunamadı"]
sections = parse_log_config(path)
problems: list[str] = []
for component, required in REQUIRED_COMPONENTS.items():
section = sections.get(component)
if section is None:
problems.append(f"[{component}] bölümü eksik")
continue
for key, value in required.items():
actual = section.get(key)
if actual is None:
problems.append(f"[{component}] {key} eksik")
elif actual.lower() != value.lower():
problems.append(f"[{component}] {key}={actual}, beklenen {value}")
return problems
def check_client_config(path: Path) -> list[str]:
"""Log boyut limiti açıksa uzun oturumlarda log kesiliyor."""
if not path.exists():
return ["client.config yok, log boyut limiti kaldırılmamış"]
sections = parse_log_config(path)
limit = sections.get("Log", {}).get("FileSizeLimit.Int")
if limit != "-1":
return [f"client.config FileSizeLimit.Int={limit}, beklenen -1"]
return []