Files
deste-hearthstone-linux-tra…/data/localdecks.py
T
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

251 lines
8.0 KiB
Python
Raw 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.
"""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