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
This commit is contained in:
yusufipk
2026-07-27 22:31:13 +07:00
commit c2a5a2d4d2
41 changed files with 4521 additions and 0 deletions
View File
+96
View File
@@ -0,0 +1,96 @@
"""Kart verisi: HearthstoneJSON'dan indirir, diske cache'ler.
Not: api.hearthstonejson.com varsayılan urllib User-Agent'ına 403 dönüyor,
bu yüzden kendi UA'mızı göndermek zorunlu.
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
CARDS_URL = "https://api.hearthstonejson.com/v1/latest/enUS/cards.json"
USER_AGENT = "deste/0.1 (kisisel hearthstone tracker; +local)"
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "deste"
CARDS_PATH = CACHE_DIR / "cards.json"
MAX_AGE_SECONDS = 7 * 24 * 3600
def _download(url: str, timeout: int = 60) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
def cache_is_stale(path: Path = CARDS_PATH, max_age: int = MAX_AGE_SECONDS) -> bool:
if not path.exists():
return True
return (time.time() - path.stat().st_mtime) > max_age
def update_cache(force: bool = False) -> bool:
"""Kart verisini indirir. Ağ yoksa mevcut cache korunur."""
if not force and not cache_is_stale():
return True
CACHE_DIR.mkdir(parents=True, exist_ok=True)
try:
payload = _download(CARDS_URL)
except (urllib.error.URLError, TimeoutError, OSError):
return CARDS_PATH.exists()
tmp = CARDS_PATH.with_suffix(".tmp")
tmp.write_bytes(payload)
tmp.replace(CARDS_PATH)
return True
class CardDB:
"""Kart kimliğinden isim, mana, sınıf gibi alanlara erişim."""
def __init__(self, cards: list[dict]):
self.by_id: dict[str, dict] = {c["id"]: c for c in cards if "id" in c}
self.by_dbf: dict[int, dict] = {c["dbfId"]: c for c in cards if "dbfId" in c}
@classmethod
def load(cls, path: Path = CARDS_PATH, auto_update: bool = True) -> "CardDB":
if auto_update:
update_cache()
if not path.exists():
raise FileNotFoundError(
f"Kart verisi yok: {path}. İnternete bağlanıp tekrar deneyin."
)
return cls(json.loads(path.read_text(encoding="utf-8")))
def get(self, card_id: str) -> dict:
return self.by_id.get(card_id, {})
def name(self, card_id: str) -> str:
return self.get(card_id).get("name", card_id or "?")
def cost(self, card_id: str) -> int:
return self.get(card_id).get("cost", 0)
def card_class(self, card_id: str) -> str:
card = self.get(card_id)
classes = card.get("classes")
if classes:
return classes[0]
return card.get("cardClass", "")
def rarity(self, card_id: str) -> str:
return self.get(card_id).get("rarity", "")
def card_type(self, card_id: str) -> str:
return self.get(card_id).get("type", "")
def dbf_id(self, card_id: str) -> int | None:
return self.get(card_id).get("dbfId")
def id_from_dbf(self, dbf_id: int) -> str:
return self.by_dbf.get(dbf_id, {}).get("id", "")
def collectible_by_dbf(self, dbf_id: int) -> dict:
return self.by_dbf.get(dbf_id, {})
+174
View File
@@ -0,0 +1,174 @@
"""Deste kütüphanesi: oyunun önbelleğinden ve kullanıcının kaydettiği deste
kodlarından desteleri toplar, maça uygun olanı seçer.
Kart kimliği (CATA_560) ile dbfId (122939) arasındaki çeviriyi burada yapıyoruz,
core katmanı yalnızca kart kimlikleriyle çalışır.
"""
from __future__ import annotations
import json
import os
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from core import deckstring
from core.state import Game
from .cards import CardDB
from .localdecks import LocalDeck, read_all_decks
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "deste"
DECKS_PATH = CONFIG_DIR / "decks.json"
@dataclass
class TrackedDeck:
name: str
cards: Counter[str] = field(default_factory=Counter) # kart kimliği -> adet
hero_card_id: str = ""
player_class: str = ""
format_name: str = ""
source: str = "local" # local (oyun önbelleği) veya manual (deste kodu)
deck_code: str = ""
@property
def card_count(self) -> int:
return sum(self.cards.values())
def _deck_from_dbf(
cards_db: CardDB,
name: str,
dbf_cards: dict[int, int],
hero_dbf: int,
format_name: str,
source: str,
deck_code: str = "",
) -> TrackedDeck:
counter: Counter[str] = Counter()
for dbf_id, count in dbf_cards.items():
card_id = cards_db.id_from_dbf(dbf_id)
if card_id:
counter[card_id] += count
hero_card_id = cards_db.id_from_dbf(hero_dbf) if hero_dbf else ""
return TrackedDeck(
name=name,
cards=counter,
hero_card_id=hero_card_id,
player_class=cards_db.card_class(hero_card_id) if hero_card_id else "",
format_name=format_name,
source=source,
deck_code=deck_code,
)
def from_local(cards_db: CardDB, deck: LocalDeck) -> TrackedDeck:
return _deck_from_dbf(
cards_db, deck.name, deck.cards, deck.hero_dbf, deck.format_name, "local"
)
def from_deck_code(cards_db: CardDB, code: str, name: str = "") -> TrackedDeck:
"""Deste kodundan takip edilebilir deste üretir."""
parsed = deckstring.parse(code)
hero = parsed.heroes[0] if parsed.heroes else 0
return _deck_from_dbf(
cards_db,
name or "Deste kodu",
parsed.as_counter(),
hero,
parsed.format_name,
"manual",
code.strip(),
)
class DeckLibrary:
"""Oyunun önbelleğindeki ve elle eklenen desteler."""
def __init__(self, cards_db: CardDB, game_dir: Path | None = None):
self.cards_db = cards_db
self.game_dir = game_dir
self.decks: list[TrackedDeck] = []
def load(self) -> "DeckLibrary":
self.decks = []
if self.game_dir is not None:
for local in read_all_decks(self.game_dir):
self.decks.append(from_local(self.cards_db, local))
self.decks.extend(self._load_manual())
return self
def _load_manual(self) -> list[TrackedDeck]:
if not DECKS_PATH.exists():
return []
try:
payload = json.loads(DECKS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
result: list[TrackedDeck] = []
for entry in payload.get("decks", []):
code = entry.get("code", "")
if not code:
continue
try:
result.append(from_deck_code(self.cards_db, code, entry.get("name", "")))
except deckstring.DeckstringError:
continue
return result
def add_manual(self, code: str, name: str = "") -> TrackedDeck:
"""Deste kodunu kaydeder ve kütüphaneye ekler."""
deck = from_deck_code(self.cards_db, code, name)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
payload = {"decks": []}
if DECKS_PATH.exists():
try:
payload = json.loads(DECKS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
entries = payload.setdefault("decks", [])
if not any(e.get("code") == deck.deck_code for e in entries):
entries.append({"name": deck.name, "code": deck.deck_code})
DECKS_PATH.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
self.decks.append(deck)
return deck
# --- eşleştirme -----------------------------------------------------
def candidates(self, player_class: str) -> list[TrackedDeck]:
if not player_class:
return list(self.decks)
return [d for d in self.decks if d.player_class == player_class]
def match(self, game: Game) -> TrackedDeck | None:
"""Maça en uygun desteyi seçer.
Sınıfa göre süzer, sonra destemizden çıkmış kartlarla çelişmeyenleri
bırakır. Tek aday kalırsa odur; birden fazla aday varsa çıkan kartları
en çok kapsayan seçilir. Hiçbiri uymuyorsa None döner ve arayüz
kullanıcıya sorar.
"""
hero = game.hero_card_id(game.local_player_id)
player_class = self.cards_db.card_class(hero) if hero else ""
candidates = self.candidates(player_class)
if not candidates:
return None
consistent = [d for d in candidates if game.deck_list_mismatch(d.cards) == 0]
pool = consistent or candidates
if len(pool) == 1:
return pool[0]
def coverage(deck: TrackedDeck) -> tuple[int, int]:
covered = sum(
min(count, deck.cards.get(card, 0))
for card, count in game.cards_left_deck.items()
)
return (covered, -game.deck_list_mismatch(deck.cards))
return max(pool, key=coverage)
+92
View File
@@ -0,0 +1,92 @@
"""Kart görselleri: art.hearthstonejson.com'dan indirir, diske önbelleğe alır.
İki boyut kullanılıyor:
tile 256x59 kart satırının arka planındaki sanat şeridi
render 256x388 fareyle üzerine gelince gösterilen tam kart (metni dahil)
İndirmeler arka plan iş parçacıklarında yapılır, arayüz beklemez. Yeni bir
görsel hazır olduğunda `generation` sayacı artar; arayüz bu sayacı izleyip
kendini tazeler. Böylece Qt sinyali gerekmiyor ve bu modül stdlib kalıyor.
"""
from __future__ import annotations
import os
import threading
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
TILE_URL = "https://art.hearthstonejson.com/v1/tiles/{card_id}.png"
RENDER_URL = "https://art.hearthstonejson.com/v1/render/latest/enUS/256x/{card_id}.png"
USER_AGENT = "deste/0.1 (kisisel hearthstone tracker; +local)"
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "deste" / "images"
TILE = "tile"
RENDER = "render"
class ImageCache:
def __init__(self, max_workers: int = 4):
self.generation = 0
self._lock = threading.Lock()
self._pending: set[tuple[str, str]] = set()
self._failed: set[tuple[str, str]] = set()
self._pool = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="deste-img")
for kind in (TILE, RENDER):
(CACHE_DIR / kind).mkdir(parents=True, exist_ok=True)
def path_for(self, card_id: str, kind: str = TILE) -> Path:
return CACHE_DIR / kind / f"{card_id}.png"
def get(self, card_id: str, kind: str = TILE) -> Path | None:
"""Görsel diskteyse yolunu döner, değilse indirmeye alır ve None döner."""
if not card_id:
return None
path = self.path_for(card_id, kind)
if path.exists():
return path
key = (card_id, kind)
with self._lock:
if key in self._pending or key in self._failed:
return None
self._pending.add(key)
self._pool.submit(self._download, card_id, kind)
return None
def prefetch(self, card_ids, kind: str = TILE) -> None:
for card_id in card_ids:
self.get(card_id, kind)
def _download(self, card_id: str, kind: str) -> None:
url = (TILE_URL if kind == TILE else RENDER_URL).format(card_id=card_id)
key = (card_id, kind)
try:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=20) as response:
payload = response.read()
except (urllib.error.URLError, TimeoutError, OSError):
with self._lock:
self._pending.discard(key)
self._failed.add(key)
return
path = self.path_for(card_id, kind)
try:
tmp = path.with_suffix(".tmp")
tmp.write_bytes(payload)
tmp.replace(path)
except OSError:
with self._lock:
self._pending.discard(key)
self._failed.add(key)
return
with self._lock:
self._pending.discard(key)
self.generation += 1
def shutdown(self) -> None:
self._pool.shutdown(wait=False, cancel_futures=True)
+250
View File
@@ -0,0 +1,250 @@
"""Oyunun çevrimdışı önbelleğinden destelerimizi okur.
Hearthstone, hesabın destelerini şu dosyada saklıyor:
<prefix>/users/<kullanıcı>/AppData/Local/Blizzard/Hearthstone/Cache/Offline/
offlineData_<hi>_<lo>_REGION_<bölge>.cache
Dosya 12 baytlık bir başlıktan sonra bölümler halinde ilerliyor:
[u32 kayıt sayısı] ( [u32 uzunluk] [protobuf payload] ) * sayı
Protobuf şemaları resmî değil, bayt seviyesinden çıkarıldı:
Deste kaydı : 1=id, 2=isim, 4=kahraman dbfId, 19=format
İçerik kaydı : 2=deste id, 3=tekrarlı yığın
yığın : 1={1=kart dbfId, 2=premium}, 3=adet
Bu biçim resmî olmadığı için her şey savunmacı yazıldı: çözülemeyen kayıt
sessizce atlanır, hiçbir durumda çökmez. Okuma başarısız olursa deste kodu
yapıştırma yolu devrede kalır.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from pathlib import Path
HEADER_SIZE = 12
MAX_RECORDS_PER_SECTION = 10_000
# Protobuf alan numaraları
F_DECK_ID = 1
F_DECK_NAME = 2
F_DECK_HERO = 4
F_DECK_FORMAT = 19
F_CONTENTS_DECK_ID = 2
F_CONTENTS_STACK = 3
F_STACK_CARD = 1
F_STACK_COUNT = 3
F_CARD_DBF = 1
FORMAT_NAMES = {1: "WILD", 2: "STANDARD", 3: "CLASSIC", 4: "TWIST"}
@dataclass(slots=True)
class LocalDeck:
deck_id: int
name: str
hero_dbf: int = 0
format_id: int = 0
cards: dict[int, int] = field(default_factory=dict) # dbfId -> adet
@property
def format_name(self) -> str:
return FORMAT_NAMES.get(self.format_id, "")
@property
def card_count(self) -> int:
return sum(self.cards.values())
# --- asgari protobuf okuyucu -------------------------------------------
def _varint(data: bytes, index: int) -> tuple[int, int]:
value = 0
shift = 0
while index < len(data):
byte = data[index]
index += 1
value |= (byte & 0x7F) << shift
if not byte & 0x80:
return value, index
shift += 7
if shift > 70:
break
raise ValueError("bozuk varint")
def parse_message(data: bytes) -> dict[int, list]:
"""Protobuf mesajını {alan: [değer]} sözlüğüne çevirir.
Uzunluk belirtilmiş alanlar ham bayt olarak döner, gerekirse çağıran
tarafından tekrar çözülür.
"""
fields: dict[int, list] = {}
index = 0
while index < len(data):
key, index = _varint(data, index)
field_number, wire_type = key >> 3, key & 7
if field_number == 0:
raise ValueError("geçersiz alan numarası")
if wire_type == 0:
value, index = _varint(data, index)
elif wire_type == 2:
length, index = _varint(data, index)
if index + length > len(data):
raise ValueError("uzunluk taşması")
value = data[index : index + length]
index += length
elif wire_type == 5:
value = data[index : index + 4]
index += 4
elif wire_type == 1:
value = data[index : index + 8]
index += 8
else:
raise ValueError(f"bilinmeyen wire type {wire_type}")
fields.setdefault(field_number, []).append(value)
return fields
def iter_records(data: bytes):
"""Dosyadaki bölümleri gezip her kaydın ham baytını verir."""
index = HEADER_SIZE
while index + 4 <= len(data):
(count,) = struct.unpack_from("<I", data, index)
index += 4
if count == 0 or count > MAX_RECORDS_PER_SECTION:
return
for _ in range(count):
if index + 4 > len(data):
return
(length,) = struct.unpack_from("<I", data, index)
index += 4
if length == 0 or index + length > len(data):
return
yield data[index : index + length]
index += length
# --- kayıt sınıflandırma -----------------------------------------------
def _as_text(raw: bytes) -> str:
return raw.decode("utf-8", errors="replace").strip()
def _parse_deck_record(fields: dict[int, list]) -> LocalDeck | None:
name_values = fields.get(F_DECK_NAME)
id_values = fields.get(F_DECK_ID)
if not name_values or not id_values:
return None
if not isinstance(name_values[0], bytes) or not isinstance(id_values[0], int):
return None
hero = fields.get(F_DECK_HERO, [0])[0]
fmt = fields.get(F_DECK_FORMAT, [0])[0]
return LocalDeck(
deck_id=id_values[0],
name=_as_text(name_values[0]),
hero_dbf=hero if isinstance(hero, int) else 0,
format_id=fmt if isinstance(fmt, int) else 0,
)
def _parse_contents_record(fields: dict[int, list]) -> tuple[int, dict[int, int]] | None:
deck_ids = fields.get(F_CONTENTS_DECK_ID)
stacks = fields.get(F_CONTENTS_STACK)
if not deck_ids or not stacks or not isinstance(deck_ids[0], int):
return None
cards: dict[int, int] = {}
for stack_raw in stacks:
if not isinstance(stack_raw, bytes):
continue
try:
stack = parse_message(stack_raw)
except ValueError:
continue
card_raw = stack.get(F_STACK_CARD, [None])[0]
count = stack.get(F_STACK_COUNT, [0])[0]
if not isinstance(card_raw, bytes) or not isinstance(count, int) or count <= 0:
continue
try:
card = parse_message(card_raw)
except ValueError:
continue
dbf = card.get(F_CARD_DBF, [None])[0]
if isinstance(dbf, int):
# Aynı kartın normal ve altın kopyaları ayrı yığın olarak geliyor.
cards[dbf] = cards.get(dbf, 0) + count
if not cards:
return None
return deck_ids[0], cards
def read_decks(cache_path: Path) -> list[LocalDeck]:
"""Önbellekten desteleri okur. Hata durumunda boş liste döner."""
try:
data = cache_path.read_bytes()
except OSError:
return []
decks: dict[int, LocalDeck] = {}
contents: dict[int, dict[int, int]] = {}
for raw in iter_records(data):
try:
fields = parse_message(raw)
except ValueError:
continue
deck = _parse_deck_record(fields)
if deck is not None:
# Aynı deste birden fazla bölümde tekrar edebiliyor.
decks.setdefault(deck.deck_id, deck)
continue
parsed = _parse_contents_record(fields)
if parsed is not None:
deck_id, cards = parsed
contents.setdefault(deck_id, cards)
for deck_id, cards in contents.items():
if deck_id in decks:
decks[deck_id].cards = cards
# Oyun aynı desteyi "yerel" ve "orijinal" olmak üzere iki kopya halinde
# önbelleğe alıyor. İsim ve içerik aynıysa tek deste olarak gösteriyoruz.
unique: dict[tuple, LocalDeck] = {}
for deck in decks.values():
if not deck.cards:
continue
signature = (deck.name, tuple(sorted(deck.cards.items())))
unique.setdefault(signature, deck)
return list(unique.values())
def find_cache_files(game_dir: Path) -> list[Path]:
"""Prefix içindeki çevrimdışı önbellek dosyalarını bulur.
Wine prefix'lerinde users/<isim> çoğu zaman users/steamuser'a sembolik
bağdır, aynı dosyayı iki kez okumamak için gerçek yola göre tekilleştiriyoruz.
"""
drive_c = game_dir.parent.parent
users = drive_c / "users"
if not users.is_dir():
return []
seen: dict[Path, Path] = {}
for user_dir in sorted(users.iterdir()):
offline = (
user_dir / "AppData" / "Local" / "Blizzard" / "Hearthstone" / "Cache" / "Offline"
)
if not offline.is_dir():
continue
for cache_file in sorted(offline.glob("offlineData_*.cache")):
seen.setdefault(cache_file.resolve(), cache_file)
return list(seen.values())
def read_all_decks(game_dir: Path) -> list[LocalDeck]:
decks: list[LocalDeck] = []
for cache_file in find_cache_files(game_dir):
decks.extend(read_decks(cache_file))
return decks