CASE STUDIESProjekt własny
Tibia Key Presser: klawisze do gry wciskane w tle
Małe narzędzie w Pythonie, które wciska hotkeye w Tibii, gdy okno gry jest zminimalizowane. Dwa lata później, przy kolejnym podejściu do projektu, wyszły pętla bez sleep, wyścig przy Stop → Start i współbieżne wysyłanie klawiszy na jednym oknie.
- Kiedy
- 2024–2026
- Rola
- Autor: aplikacja, build, rozwój
- Stack
- PythonTkinterpywinautoPyInstallerpytest
- Linki
- repozytorium.exe
8
klawiszy, każdy z własnym opóźnieniem
100 ms
maksymalny czas reakcji na Stop
780
linii Pythona w wersji wyjściowej
Problem
Tibia ma powtarzalne akcje pod hotkeyami: leczenie, mana, runy. Chciałem, żeby program wciskał je sam w stałym rytmie, także gdy okno gry jest zminimalizowane, a ja robię coś innego. Symulowana klawiatura idzie do aktywnego okna, więc trzeba trafić w konkretne okno gry, a nie w to, które akurat ma fokus.
Pierwsza wersja z września 2024 działała, ale miała błędy, które zobaczyłem dopiero po powrocie do projektu w październiku 2026. Tkinter zgłasza nazwy klawiszy (Return, space, comma), których pywinauto nie zna; nieznany klawisz rzucał wyjątek, kod traktował go jak utratę okna, łączył się ponownie i próbował znowu. Pętla bez żadnego sleep, z tracebackiem co iterację i bez sygnału w interfejsie. Stop i Start w odstępie poniżej 100 ms wskrzeszały stare wątki, bo zdarzenie stop było jedno na cały czas życia aplikacji. Kolory, limity i ścieżki żyły w trzech miejscach naraz.
Podejście
Wysyłka idzie przez pywinauto do uchwytu okna znalezionego po tytule „<klient> - <postać>” (wyrażenie regularne, opcjonalny clients.txt dla innych serwerów), a nie do aktywnego okna. Każdy klawisz ma własny wątek z absolutnym terminem następnego wciśnięcia, więc narzut wyszukiwania okna i wysyłki nie kumuluje się w dryf. Sen jest cięty na odcinki po 100 ms, żeby Stop reagował natychmiast, a uchwyt okna jest sprawdzany co 5 sekund.
Zanim ruszyłem z kodem, spisałem analizę: błędy z wagą, duplikacje, martwy kod, plan zmian etapami i pytania do decyzji. Zachowanie pywinauto sprawdziłem w źródłach wersji 0.6.9 zamiast zakładać, co robi.

Rozwiązanie
- Moduł keys.py: czysta funkcja tłumacząca nazwy klawiszy tkintera na kody pywinauto (Return na ENTER, Prior na PGUP, interpunkcja na znaki, cały numpad), a same modyfikatory nie wiążą się, więc Shift+F1 wiąże F1. Walidacja w chwili bindowania: nieobsługiwany klawisz to czerwone pole i komunikat, bez możliwości startu. Sześć testów w pytest.
- Pętla wysyłki odczekuje interwał po nieudanej wysyłce, więc trwały błąd nie zamienia się w busy loop.
- Jedno źródło prawdy: theme.py dla kolorów, fontów i stylów ttk, paths.py dla ścieżek w trybie źródłowym i w pliku .exe, więc ikona przestała zależeć od bieżącego katalogu.
- Narzędzia: pyproject z uv, ruff i pytest, build do pojedynczego .exe przez PyInstaller, clients.txt dla serwerów OTS.
- Zaplanowane, jeszcze nie wdrożone: jeden wątek-scheduler zamiast wątku na klawisz. W źródłach pywinauto wysyłka to SetKeyboardState i AttachThreadInput na współdzielonej kolejce wejścia, bez żadnej blokady, więc dwa wątki z pokrywającymi się terminami mogą sobie nadpisać stan klawiatury. Zserializowane wątki mają tę samą przepustowość co jeden scheduler, a Stop staje się set i join.
Efekt
Do ośmiu klawiszy z niezależnymi opóźnieniami, działa przy zminimalizowanej grze, zatrzymuje się w około 100 ms i pokazuje nazwę postaci w tytule okna. Jeden plik .exe bez instalacji.

Czego się nauczyłem
- Wyjątek łapany jako coś innego (nieznany klawisz jako utrata okna) tworzy pętle, których interfejs nie widzi. Każda gałąź błędu potrzebuje własnej reakcji i własnego odczekania.
- Powrót do własnego kodu po dwóch latach zaczyna się od spisanej analizy z wagami i planem. Zmiany robione przy okazji kosztują więcej.