Co-authored-by: Artur Tamazian <artur-tamazian>
Co-authored-by: Javier Gonzalez <javierggt@yahoo.com>
This commit is contained in:
authored and GitHub committed 2026-06-05 13:22:00 +02:00
1 parent 6555001fe9
commit ad244ffc7e
34 files changed
+2285 -464

No files matched your search

+9 -4
View File
@@ -15,11 +15,16 @@
The easiest way to install is probably [brew](https://brew.sh/). Simply run `brew install katrain` and it will download and install the latest pre-built .app, and also install katago if needed. The easiest way to install is probably [brew](https://brew.sh/). Simply run `brew install katrain` and it will download and install the latest pre-built .app, and also install katago if needed.
You can also find downloadable .app files for macOS [here](https://github.com/sanderland/katrain/releases). You can also find downloadable macOS installers [here](https://github.com/sanderland/katrain/releases). Recent releases include both Intel (`KaTrain-*-x86_64.dmg`) and Apple Silicon (`KaTrain-*-arm64.dmg`) installers, so download the one matching your Mac. Mount the `.dmg` and drag the `.app` to your Applications folder.
Simply download, unzip the file, mount the .dmg and drag the .app file to your application folder, everything is included.
The first time launching the application you may need to [control-click in finder to give permission for the 'unidentified' app to launch](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac). This is simply a result of Apple charging $99/year to developers to be 'identified'.
Users with the last generation M1 macs with different architecture should then `brew install katago` in addition to this. KaTrain will automatically detect this KataGo binary. The first time you launch the app, macOS may block it as an app from an unknown developer.
Follow Apple's current guide [here](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac):
1. Try opening the app once from Finder.
2. Open `System Settings > Privacy & Security`.
3. In the Security section, click `Open` and then `Open Anyway` for KaTrain.
4. Enter your password to confirm.
This is simply a result of Apple charging $99/year to developers to be 'identified'.
### <a name="MacCommand"></a>Command line install guide ### <a name="MacCommand"></a>Command line install guide
+38 -9
View File
@@ -29,6 +29,12 @@ else:
Config.set("kivy", "window_icon", ICON) Config.set("kivy", "window_icon", ICON)
Config.set("input", "mouse", "mouse,multitouch_on_demand") Config.set("input", "mouse", "mouse,multitouch_on_demand")
# Preload sounds before Window import creates the SDL2 window,
# as SoundLoader.load() deadlocks once the SDL2 window exists.
from katrain.gui.sound import preload_sounds
preload_sounds(PATHS["PACKAGE"] + "/sounds")
# next, certificates on package builds https://github.com/sanderland/katrain/issues/414 # next, certificates on package builds https://github.com/sanderland/katrain/issues/414
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
import ssl import ssl
@@ -93,7 +99,7 @@ from katrain.gui.popups import (
) )
from katrain.gui.sound import play_sound from katrain.gui.sound import play_sound
from katrain.core.base_katrain import KaTrainBase from katrain.core.base_katrain import KaTrainBase
from katrain.core.engine import KataGoEngine from katrain.core.remote_engine import make_engine
from katrain.core.contribute_engine import KataGoContributeEngine from katrain.core.contribute_engine import KataGoContributeEngine
from katrain.core.game import Game, IllegalMoveException, KaTrainSGF, BaseGame from katrain.core.game import Game, IllegalMoveException, KaTrainSGF, BaseGame
from katrain.core.sgf_parser import Move, ParseError from katrain.core.sgf_parser import Move, ParseError
@@ -187,7 +193,7 @@ class KaTrainGui(Screen, KaTrainBase):
if self.engine: if self.engine:
return return
self.board_gui.trainer_config = self.config("trainer") self.board_gui.trainer_config = self.config("trainer")
self.engine = KataGoEngine(self, self.config("engine")) self.engine = make_engine(self, self.config("engine"))
threading.Thread(target=self._message_loop_thread, daemon=True).start() threading.Thread(target=self._message_loop_thread, daemon=True).start()
sgf_args = [ sgf_args = [
f f
@@ -207,6 +213,20 @@ class KaTrainGui(Screen, KaTrainBase):
MDApp.get_running_app().root_window.bind(focus=set_focus_event) MDApp.get_running_app().root_window.bind(focus=set_focus_event)
def restart_engine(self):
"""Rebuild the analysis engine from current config and re-analyze.
Shared by the engine-settings save and the recovery popup's Retry."""
self.log("Restarting engine", OUTPUT_INFO)
self.controls.set_status(i18n._("restarting engine"), STATUS_INFO)
old_engine = self.engine
old_engine.shutdown(finish=False)
new_engine = make_engine(self, self.config("engine"))
self.engine = new_engine
self.game.engines = {"B": new_engine, "W": new_engine}
# old engine was possibly broken, so make sure we redo any failures
self.game.analyze_all_nodes(analyze_fast=True)
self.update_state()
def update_gui(self, cn, redraw_board=False): def update_gui(self, cn, redraw_board=False):
# Handle prisoners and next player display # Handle prisoners and next player display
prisoners = self.game.prisoner_count prisoners = self.game.prisoner_count
@@ -226,7 +246,7 @@ class KaTrainGui(Screen, KaTrainBase):
self.controls.players["B"].captures = prisoners["B"] self.controls.players["B"].captures = prisoners["B"]
# update engine status dot # update engine status dot
if not self.engine or not self.engine.katago_process or self.engine.katago_process.poll() is not None: if not self.engine or not self.engine.check_alive():
self.board_controls.engine_status_col = Theme.ENGINE_DOWN_COLOR self.board_controls.engine_status_col = Theme.ENGINE_DOWN_COLOR
elif self.engine.is_idle(): elif self.engine.is_idle():
self.board_controls.engine_status_col = Theme.ENGINE_READY_COLOR self.board_controls.engine_status_col = Theme.ENGINE_READY_COLOR
@@ -512,7 +532,7 @@ class KaTrainGui(Screen, KaTrainBase):
self.ai_settings_popup.content.popup = self.ai_settings_popup self.ai_settings_popup.content.popup = self.ai_settings_popup
self.ai_settings_popup.open() self.ai_settings_popup.open()
def _do_engine_recovery_popup(self, error_message, code): def _do_engine_recovery_popup(self, error_message, code, engine_type="local"):
current_open = self.popup_open current_open = self.popup_open
if current_open and isinstance(current_open.content, EngineRecoveryPopup): if current_open and isinstance(current_open.content, EngineRecoveryPopup):
self.log(f"Not opening engine recovery popup with {error_message} as one is already open", OUTPUT_DEBUG) self.log(f"Not opening engine recovery popup with {error_message} as one is already open", OUTPUT_DEBUG)
@@ -520,7 +540,7 @@ class KaTrainGui(Screen, KaTrainBase):
popup = I18NPopup( popup = I18NPopup(
title_key="engine recovery", title_key="engine recovery",
size=[dp(600), dp(700)], size=[dp(600), dp(700)],
content=EngineRecoveryPopup(self, error_message=error_message, code=code), content=EngineRecoveryPopup(self, error_message=error_message, code=code, engine_type=engine_type),
).__self__ ).__self__
popup.content.popup = popup popup.content.popup = popup
popup.open() popup.open()
@@ -845,14 +865,19 @@ class KaTrainApp(MDApp):
def is_valid_window_position(self, left, top, width, height): def is_valid_window_position(self, left, top, width, height):
try: try:
from screeninfo import get_monitors from screeninfo import get_monitors
monitors = get_monitors() monitors = get_monitors()
for monitor in monitors: for monitor in monitors:
if (left >= monitor.x and left + width <= monitor.x + monitor.width and if (
top >= monitor.y and top + height <= monitor.y + monitor.height): left >= monitor.x
and left + width <= monitor.x + monitor.width
and top >= monitor.y
and top + height <= monitor.y + monitor.height
):
return True return True
return False return False
except Exception as e: except Exception as e:
return True # yolo return True # yolo
def build(self): def build(self):
self.icon = ICON # how you're supposed to set an icon self.icon = ICON # how you're supposed to set an icon
@@ -905,7 +930,11 @@ class KaTrainApp(MDApp):
win_size = [1300 * window_scale_fac, 1000 * window_scale_fac] win_size = [1300 * window_scale_fac, 1000 * window_scale_fac]
self.gui.log(f"Setting window size to {win_size} and position to {[win_left, win_top]}", OUTPUT_DEBUG) self.gui.log(f"Setting window size to {win_size} and position to {[win_left, win_top]}", OUTPUT_DEBUG)
Window.size = (win_size[0], win_size[1]) Window.size = (win_size[0], win_size[1])
if win_left is not None and win_top is not None and self.is_valid_window_position(win_left, win_top, win_size[0], win_size[1]): if (
win_left is not None
and win_top is not None
and self.is_valid_window_position(win_left, win_top, win_size[0], win_size[1])
):
Window.left = win_left Window.left = win_left
Window.top = win_top Window.top = win_top
+2
View File
@@ -1,7 +1,9 @@
{ {
"engine": { "engine": {
"backend": "",
"katago": "", "katago": "",
"altcommand": "", "altcommand": "",
"remote_url": "",
"model": "katrain/models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz", "model": "katrain/models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz",
"humanlike_model": "", "humanlike_model": "",
"config": "katrain/KataGo/analysis_config.cfg", "config": "katrain/KataGo/analysis_config.cfg",
+1 -1
View File
@@ -1,5 +1,5 @@
PROGRAM_NAME = "KaTrain" PROGRAM_NAME = "KaTrain"
VERSION = "1.17.1" VERSION = "1.18.0"
HOMEPAGE = "https://github.com/sanderland/katrain" HOMEPAGE = "https://github.com/sanderland/katrain"
CONFIG_MIN_VERSION = "1.17.0" # keep config files from this version CONFIG_MIN_VERSION = "1.17.0" # keep config files from this version
ANALYSIS_FORMAT_VERSION = "1.0" ANALYSIS_FORMAT_VERSION = "1.0"
+22 -2
View File
@@ -26,6 +26,25 @@ from katrain.core.lang import i18n
from katrain.core.sgf_parser import Move from katrain.core.sgf_parser import Move
from katrain.core.utils import find_package_resource, json_truncate_arrays from katrain.core.utils import find_package_resource, json_truncate_arrays
ENGINE_BACKENDS = ("local", "remote", "custom")
def resolve_engine_backend(config) -> str:
"""Which engine backend to use: 'local', 'remote' or 'custom'.
Uses the explicit `engine/backend` setting when present (set by the engine
settings tabs). Falls back to deriving it from the legacy fields so older
configs keep working: a remote_url means remote, an altcommand means custom.
"""
backend = (config.get("backend") or "").strip().lower()
if backend in ENGINE_BACKENDS:
return backend
if (config.get("remote_url") or "").strip():
return "remote"
if (config.get("altcommand") or "").strip():
return "custom"
return "local"
class BaseEngine: # some common elements between analysis and contribute engine class BaseEngine: # some common elements between analysis and contribute engine
@@ -95,6 +114,7 @@ class KataGoEngine(BaseEngine):
"""Starts and communicates with the KataGO analysis engine""" """Starts and communicates with the KataGO analysis engine"""
PONDER_KEY = "_kt_continuous" PONDER_KEY = "_kt_continuous"
ENGINE_TYPE = "local" # passed to the recovery popup so it can tailor its advice
def __init__(self, katrain, config): def __init__(self, katrain, config):
super().__init__(katrain, config) super().__init__(katrain, config)
@@ -112,7 +132,7 @@ class KataGoEngine(BaseEngine):
self.shell = False self.shell = False
self.write_queue = queue.Queue() self.write_queue = queue.Queue()
self.thread_lock = threading.Lock() self.thread_lock = threading.Lock()
if config.get("altcommand", ""): if resolve_engine_backend(config) == "custom":
self.command = config["altcommand"] self.command = config["altcommand"]
self.shell = True self.shell = True
else: else:
@@ -146,7 +166,7 @@ class KataGoEngine(BaseEngine):
def on_error(self, message, code=None, allow_popup=True): def on_error(self, message, code=None, allow_popup=True):
self.katrain.log(message, OUTPUT_ERROR) self.katrain.log(message, OUTPUT_ERROR)
if self.allow_recovery and allow_popup: if self.allow_recovery and allow_popup:
self.katrain("engine_recovery_popup", message, code) self.katrain("engine_recovery_popup", message, code, self.ENGINE_TYPE)
def start(self): def start(self):
with self.thread_lock: with self.thread_lock:
+584
View File
@@ -0,0 +1,584 @@
"""Remote KataGo Analysis Engine over WebSocket.
When `engine.remote_url` is set in the config, KaTrain runs queries
against a remote KataGo Analysis Engine instead of spawning a local
subprocess. The server is expected to expose the KataGo Analysis
Engine JSON protocol over a WebSocket.
Generic transport — any KataGo-compatible server works. KaTrain has
no awareness of who hosts the engine.
"""
from __future__ import annotations
import json
import queue
import threading
import time
import traceback
import certifi
from websocket import ( # provided by `websocket-client`
ABNF,
WebSocket,
WebSocketException,
WebSocketTimeoutException,
create_connection,
)
from katrain.core.constants import (
OUTPUT_DEBUG,
OUTPUT_ERROR,
OUTPUT_EXTRA_DEBUG,
OUTPUT_INFO,
STATUS_INFO,
)
from katrain.core.engine import BaseEngine, KataGoEngine, resolve_engine_backend
from katrain.core.lang import i18n
from katrain.core.utils import json_truncate_arrays
class RemoteKataGoEngine(KataGoEngine):
"""KataGo engine that talks to a remote Analysis Engine server
over a WebSocket instead of spawning a local subprocess.
Activated when `engine.remote_url` in the config is non-empty.
The URL must be `ws://...` or `wss://...`.
"""
ENGINE_TYPE = "remote" # recovery popup shows remote-specific advice (check URL, not executable)
READ_TIMEOUT_S = 120
# A dropped WebSocket is recoverable (server restart, flaky network),
# unlike a crashed local subprocess. Reconnect transparently with
# linear backoff before falling back to the recovery popup.
RECONNECT_ATTEMPTS = 6
RECONNECT_BACKOFF_S = 1.0
RECONNECT_MAX_BACKOFF_S = 10.0
def __init__(self, katrain, config):
# Bypass KataGoEngine.__init__'s subprocess setup; initialise
# only the bookkeeping the rest of KaTrain reads.
BaseEngine.__init__(self, katrain, config)
self.allow_recovery = self.config.get("allow_recovery", True)
self.queries = {}
# query id -> JSON payload, kept so outstanding analyses can be
# re-sent after a reconnect (the server loses them on disconnect).
self.sent_payloads = {}
self.ponder_query = None
self.query_counter = 0
self.katago_process = None # rest of the codebase checks this
self.base_priority = 0
self.override_settings = {"reportAnalysisWinratesAs": "BLACK"}
self.write_queue = queue.Queue()
self.thread_lock = threading.Lock()
self.shell = False
self.command = "<remote websocket>"
self.remote_url = (config.get("remote_url") or "").strip()
if not self.remote_url:
self.on_error(
i18n._("Remote KataGo URL is empty"),
"REMOTE-URL-MISSING",
allow_popup=False,
)
return
if not self.remote_url.startswith(("ws://", "wss://")):
self.on_error(
i18n._("Remote KataGo URL must start with ws:// or wss://"),
"REMOTE-URL-INVALID",
allow_popup=False,
)
return
self.ws: WebSocket | None = None
self.ws_send_lock = threading.Lock()
self.analysis_thread = None
self.write_stdin_thread = None
self.stderr_thread = None
self._closing = False
self._reported_dead = False
self._reconnecting = False
# Bumped on each (re)connect. I/O threads capture the value they
# were started with so a stale thread blocked on a dead socket
# can't trigger a reconnect that would tear down a newer one.
self._conn_id = 0
self.start()
def _create_connection(self) -> WebSocket:
# macOS's bundled Python may have no configured CA bundle,
# so provide certifi explicitly for secure WebSockets.
sslopt = {"ca_certs": certifi.where()} if self.remote_url.startswith("wss://") else None
return create_connection(
self.remote_url,
timeout=self.READ_TIMEOUT_S,
enable_multithread=True,
sslopt=sslopt,
)
def _start_threads(self):
"""Bump the connection generation and launch fresh I/O threads
bound to it. Must be called while holding thread_lock."""
self._conn_id += 1
conn_id = self._conn_id
self.analysis_thread = threading.Thread(
target=self._analysis_read_thread,
args=(conn_id,),
daemon=True,
)
self.write_stdin_thread = threading.Thread(
target=self._write_stdin_thread,
args=(conn_id,),
daemon=True,
)
# Dummy stderr thread so callers that join all three don't
# crash on None — remote engines have no stderr channel.
self.stderr_thread = threading.Thread(
target=lambda: None,
daemon=True,
)
self.analysis_thread.start()
self.write_stdin_thread.start()
self.stderr_thread.start()
def start(self):
with self.thread_lock:
self.write_queue = queue.Queue()
self._closing = False
self._reported_dead = False
self._reconnecting = False
try:
self.katrain.log(
f"Connecting to remote KataGo at {self.remote_url}",
OUTPUT_DEBUG,
)
self.ws = self._create_connection()
except Exception as e:
self.on_error(
i18n._("Connecting to remote KataGo failed").format(
url=self.remote_url,
error=e,
),
code="REMOTE-CONNECT",
)
self.ws = None
return
self._start_threads()
def _handle_disconnect(self, os_error="", conn_id=None):
"""Called by the read/write threads when the WebSocket drops.
Kicks off a single background reconnect attempt instead of
immediately surfacing the (local-engine-oriented) recovery popup.
`conn_id` is the generation the calling thread was started with;
a mismatch means a newer connection already exists and this is a
stale thread, so we ignore it.
"""
with self.thread_lock:
if self._closing or self._reconnecting:
return
if conn_id is not None and conn_id != self._conn_id:
return
self._reconnecting = True
old_ws, self.ws = self.ws, None
# Closing the dead socket unblocks the sibling thread still
# parked in recv() so it exits promptly rather than hanging.
if old_ws is not None:
try:
old_ws.close()
except Exception:
pass
threading.Thread(
target=self._reconnect_thread,
args=(os_error,),
daemon=True,
).start()
def _reconnect_thread(self, os_error=""):
"""Try to re-establish the WebSocket with linear backoff. On
success, relaunch the I/O threads and re-send outstanding
queries. Only if every attempt fails do we report the engine as
dead (which opens the recovery popup)."""
reconnected = False
try:
for attempt in range(1, self.RECONNECT_ATTEMPTS + 1):
if self._closing:
return
delay = min(self.RECONNECT_BACKOFF_S * attempt, self.RECONNECT_MAX_BACKOFF_S)
self.katrain.log(
f"Remote KataGo disconnected ({os_error}); "
f"reconnect attempt {attempt}/{self.RECONNECT_ATTEMPTS} in {delay:.0f}s",
OUTPUT_INFO,
)
self._set_status(f"Reconnecting to remote KataGo (attempt {attempt}/{self.RECONNECT_ATTEMPTS})...")
time.sleep(delay)
if self._closing:
return
try:
ws = self._create_connection()
except Exception as e:
os_error = str(e)
continue
with self.thread_lock:
if self._closing:
try:
ws.close()
except Exception:
pass
return
self.ws = ws
self._reported_dead = False
self._start_threads()
self.katrain.log("Reconnected to remote KataGo", OUTPUT_INFO)
self._set_status("Reconnected to remote KataGo.")
self._resend_outstanding()
reconnected = True
return
finally:
# Keep _reconnecting set across the failure report below so
# external pollers (check_alive) stay quiet and can't latch
# _reported_dead in a way that suppresses the popup. Only clear
# it here on success/shutdown.
if reconnected or self._closing:
self._reconnecting = False
# Every attempt failed and we are not shutting down: report dead and
# open the recovery popup. _report_dead is idempotent, so a later
# poll from check_alive won't double-report.
if not self._closing:
self._report_dead(os_error, allow_popup=True)
self._reconnecting = False
def _resend_outstanding(self):
"""Re-send queries that were in flight when the connection
dropped. Keyed off self.queries so anything cleared by a new
game or an explicit terminate is not resurrected."""
with self.thread_lock:
payloads = [self.sent_payloads[qid] for qid in self.queries if qid in self.sent_payloads]
if not payloads:
return
self.katrain.log(
f"Re-sending {len(payloads)} outstanding queries after reconnect",
OUTPUT_INFO,
)
for query in payloads:
ws = self.ws
if ws is None:
return
try:
with self.ws_send_lock:
ws.send(json.dumps(query))
except Exception as e:
self.katrain.log(f"Failed to re-send query after reconnect: {e}", OUTPUT_ERROR)
return
def _set_status(self, message):
try:
self.katrain.controls.set_status(message, STATUS_INFO)
except Exception:
pass
def on_new_game(self):
# Parent clears self.queries; drop the payloads too so a later
# reconnect doesn't resurrect a previous game's analyses.
super().on_new_game()
self.sent_payloads = {}
def shutdown(self, finish=False):
self._closing = True
ws = self.ws
if finish and ws is not None:
self.wait_to_finish()
self.ws = None
self.sent_payloads = {}
if ws is not None:
try:
ws.close()
except Exception:
pass
if finish is not None:
for t in [self.write_stdin_thread, self.analysis_thread, self.stderr_thread]:
if t and t.is_alive():
t.join(timeout=2.0)
def wait_to_finish(self):
while self.queries and self.ws is not None:
time.sleep(0.1)
def _report_dead(self, os_error, allow_popup):
"""Report the engine as disconnected exactly once. Guarded by
thread_lock + _reported_dead so a poll from check_alive and the
reconnect thread's final failure report can't double-fire or race."""
with self.thread_lock:
if self._reported_dead:
return
self._reported_dead = True
self.ws = None
self.on_error(
i18n._("Remote KataGo engine disconnected").format(error=os_error),
code="REMOTE-DISCONNECTED",
allow_popup=allow_popup,
)
def check_alive(self, os_error="", exception_if_dead=False, maybe_open_recovery=False):
# An in-progress auto-reconnect is recovery, not death. Callers that
# poll while the socket is briefly None (the AI move loops in ai.py
# spin on check_alive every 10ms) must not flag the engine dead.
# _reconnect_thread keeps _reconnecting set until it has reported a
# genuine failure, so the popup is never suppressed nor premature.
if self._reconnecting and not self._closing:
return True
ok = self.ws is not None and not self._closing
if not ok and exception_if_dead:
self._report_dead(os_error, allow_popup=maybe_open_recovery)
return ok
def _read_stderr_thread(self):
# Remote engines have no stderr channel; warnings come via the
# `warning` field on responses. Override prevents the parent's
# stderr thread from reading from a None process.
return
def _write_stdin_thread(self, conn_id):
"""Pop (query, callback, error_callback, next_move, node)
tuples off write_queue, register the callback in self.queries
so the read thread can match responses by id, then send the
JSON query over the WebSocket. Ponder dedupe lives inside the
lock so rapid Ponder presses don't queue duplicate analyses.
Bound to the connection generation `conn_id`: once a reconnect
supersedes it, this thread stops and a fresh one takes over.
"""
ws = self.ws
while ws is not None and not self._closing and conn_id == self._conn_id:
try:
query, callback, error_callback, next_move, node = self.write_queue.get(
block=True,
timeout=0.1,
)
except queue.Empty:
continue
if self._closing or conn_id != self._conn_id:
# Superseded mid-pop: hand the item back so the current
# generation's writer sends it after reconnecting.
self.write_queue.put((query, callback, error_callback, next_move, node))
return
with self.thread_lock:
if "id" not in query:
self.query_counter += 1
query["id"] = f"QUERY:{self.query_counter}"
ponder = query.pop(self.PONDER_KEY, False)
if ponder:
pq = self.ponder_query or {}
differences = {
k: (pq.get(k), query.get(k))
for k in (query.keys() | pq.keys()) - {"id", "maxVisits", "reportDuringSearchEvery"}
if pq.get(k) != query.get(k)
}
if differences:
self.stop_pondering()
query["maxVisits"] = 10_000_000
from katrain.core.constants import PONDERING_REPORT_DT
query["reportDuringSearchEvery"] = PONDERING_REPORT_DT
self.ponder_query = query
else:
continue
terminate = query.get("action") == "terminate"
if not terminate:
self.queries[query["id"]] = (
callback,
error_callback,
time.time(),
next_move,
node,
)
self.sent_payloads[query["id"]] = query
tag = "ponder " if ponder else ("terminate " if terminate else "")
self.katrain.log(
f"Sending {tag}query {query['id']}: {json.dumps(query)}",
OUTPUT_DEBUG,
)
try:
payload = json.dumps(query)
with self.ws_send_lock:
ws.send(payload)
except WebSocketException as e:
self._handle_disconnect(os_error=str(e), conn_id=conn_id)
return
except Exception as e:
self.katrain.log(
f"Unexpected exception sending to remote KataGo: {e}",
OUTPUT_ERROR,
)
traceback.print_exc()
self._handle_disconnect(os_error=str(e), conn_id=conn_id)
return
def _analysis_read_thread(self, conn_id):
"""Read JSON responses from the WebSocket and dispatch to the
matching callbacks in self.queries.
Bound to the connection generation `conn_id` and to the socket
captured at start, so a thread left over from a previous
connection never reads from (or tears down) a newer one.
"""
ws = self.ws
while ws is not None and not self._closing and conn_id == self._conn_id:
try:
# recv_data(control_frame=True) lets us inspect non-text frames
# (e.g. close, whose payload carries the status code + reason).
opcode, data = ws.recv_data(control_frame=True)
except WebSocketTimeoutException:
continue
except WebSocketException as e:
if self._closing:
return
self._handle_disconnect(os_error=str(e), conn_id=conn_id)
return
except Exception as e:
if self._closing:
return
self.katrain.log(
f"Unexpected exception reading from remote KataGo: {e}",
OUTPUT_ERROR,
)
traceback.print_exc()
self._handle_disconnect(os_error=str(e), conn_id=conn_id)
return
if opcode == ABNF.OPCODE_CLOSE:
if not self._closing:
# RFC 6455 close payload: 2-byte status code + UTF-8 reason.
reason = "closed by remote"
if data and len(data) >= 2:
reason = data[2:].decode("utf-8", errors="replace").strip() or reason
self._handle_disconnect(os_error=reason, conn_id=conn_id)
return
if opcode not in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY):
# ping/pong/continuation — nothing to dispatch.
continue
if not data:
continue
raw = data.decode("utf-8") if isinstance(data, bytes) else data
# A frame may contain multiple newline-delimited JSON
# objects (matches KataGo's stdio framing).
for line in str(raw).splitlines():
line = line.strip()
if not line:
continue
self._dispatch_response_line(line)
def _dispatch_response_line(self, line: str) -> None:
"""Parse one JSON response line and dispatch to the matching
query callback. Warning text is mirrored to the UI status
panel so users see notices like "visits capped" without
reading the log."""
try:
analysis = json.loads(line)
except json.JSONDecodeError as e:
self.katrain.log(
f"Bad JSON from remote KataGo: {e} (line: {line[:200]!r})",
OUTPUT_ERROR,
)
return
try:
if "id" not in analysis:
self.katrain.log(
f"Error without ID {analysis} received from remote KataGo",
OUTPUT_ERROR,
)
return
query_id = analysis["id"]
if query_id not in self.queries:
if analysis.get("action") != "terminate":
self.katrain.log(
f"Query result {query_id} discarded -- recent new game or node reset?",
OUTPUT_DEBUG,
)
return
callback, error_callback, start_time, next_move, _ = self.queries[query_id]
# Handled BEFORE the dispatch chain so analysis data
# alongside a warning still reaches the callback.
if "warning" in analysis:
warning_text = str(analysis.get("warning"))
self.katrain.log(
f"Remote KataGo warning: {warning_text}",
OUTPUT_INFO,
)
try:
self.katrain.controls.set_status(warning_text, STATUS_INFO)
except Exception:
pass
if "error" in analysis:
del self.queries[query_id]
self.sent_payloads.pop(query_id, None)
if error_callback:
error_callback(analysis)
elif not (next_move and "Illegal move" in analysis["error"]):
self.katrain.log(
f"{analysis} received from remote KataGo",
OUTPUT_ERROR,
)
elif "terminateId" in analysis:
self.katrain.log(
f"{analysis} received from remote KataGo",
OUTPUT_DEBUG,
)
else:
partial_result = analysis.get("isDuringSearch", False)
if not partial_result:
del self.queries[query_id]
self.sent_payloads.pop(query_id, None)
time_taken = time.time() - start_time
results_exist = not analysis.get("noResults", False)
self.katrain.log(
f"[{time_taken:.1f}][{query_id}][{'....' if partial_result else 'done'}] "
f"KataGo analysis received: {len(analysis.get('moveInfos', []))} "
f"candidate moves, "
f"{analysis['rootInfo']['visits'] if results_exist else 'n/a'} visits",
OUTPUT_DEBUG,
)
self.katrain.log(json_truncate_arrays(analysis), OUTPUT_EXTRA_DEBUG)
try:
if callback and results_exist:
callback(analysis, partial_result)
except Exception as e:
self.katrain.log(
f"Error in engine callback for query {query_id}: {e}",
OUTPUT_ERROR,
)
traceback.print_exc()
if getattr(self.katrain, "update_state", None):
self.katrain.update_state()
except Exception as e:
self.katrain.log(
f"Unexpected exception {e} processing remote KataGo output: {line[:200]!r}",
OUTPUT_ERROR,
)
traceback.print_exc()
def make_engine(katrain, config):
"""Return the engine matching the selected backend (see resolve_engine_backend):
a RemoteKataGoEngine for the remote backend, otherwise a local-subprocess
KataGoEngine (which itself handles the local vs custom-command distinction)."""
if resolve_engine_backend(config) == "remote":
return RemoteKataGoEngine(katrain, config)
return KataGoEngine(katrain, config)
+62 -27
View File
@@ -24,6 +24,7 @@ from kivymd.uix.selectioncontrol import MDCheckbox
from kivymd.uix.textfield import MDTextField from kivymd.uix.textfield import MDTextField
from katrain.core.ai import ai_rank_estimation, game_report from katrain.core.ai import ai_rank_estimation, game_report
from katrain.core.engine import resolve_engine_backend
from katrain.core.constants import ( from katrain.core.constants import (
AI_CONFIG_DEFAULT, AI_CONFIG_DEFAULT,
AI_DEFAULT, AI_DEFAULT,
@@ -39,7 +40,6 @@ from katrain.core.constants import (
PLAYER_HUMAN, PLAYER_HUMAN,
ADDITIONAL_MOVE_ORDER, ADDITIONAL_MOVE_ORDER,
) )
from katrain.core.engine import KataGoEngine
from katrain.core.lang import i18n, rank_label from katrain.core.lang import i18n, rank_label
from katrain.core.sgf_parser import Move from katrain.core.sgf_parser import Move
from katrain.core.utils import PATHS, find_package_resource, evaluation_class from katrain.core.utils import PATHS, find_package_resource, evaluation_class
@@ -455,11 +455,36 @@ class ConfigAIPopup(QuickConfigGui):
class EngineRecoveryPopup(QuickConfigGui): class EngineRecoveryPopup(QuickConfigGui):
error_message = StringProperty("") error_message = StringProperty("")
code = ObjectProperty(None) code = ObjectProperty(None)
engine_type = StringProperty("local")
recovery_message = StringProperty("")
def __init__(self, katrain, error_message, code): def __init__(self, katrain, error_message, code, engine_type="local"):
super().__init__(katrain) super().__init__(katrain)
self.error_message = str(error_message) self.error_message = str(error_message)
self.code = code self.code = code
self.engine_type = engine_type or "local"
self.recovery_message = self._build_message()
def _build_message(self):
settings_link = "[color=#CCCC11][u][ref=engine_settings]" + i18n._("menu:settings") + "[/ref][/u][/color]"
help_link = "[color=#CCCC11][u][ref=engine_help]" + i18n._("link_here") + "[/ref][/u][/color]"
if self.engine_type == "remote":
opening_key = "remote engine disconnected popup opening message"
suggestion = i18n._("remote engine check url suggestion").format(link=settings_link)
else:
opening_key = "engine died popup opening message"
suggestion = i18n._("change engine suggestion").format(link=settings_link)
opening = i18n._(opening_key).format(code=self.code, error_message=self.error_message)
help_text = i18n._("go to engine help page").format(link=help_link)
return opening + "\n\n" + suggestion + "\n\n" + help_text
def retry(self):
"""Rebuild the engine from current config and re-analyze. For a
remote engine this reconnects; for a local one it respawns the
subprocess. Recovers a transient failure without changing settings."""
if self.popup:
self.popup.dismiss()
Clock.schedule_once(lambda _dt: self.katrain.restart_engine(), 0)
class BaseConfigPopup(QuickConfigGui): class BaseConfigPopup(QuickConfigGui):
@@ -481,23 +506,28 @@ class BaseConfigPopup(QuickConfigGui):
KATAGOS = { KATAGOS = {
"win": { "win": {
"OpenCL v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-opencl-windows-x64.zip", "OpenCL v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-opencl-windows-x64.zip",
"Eigen AVX2 (Modern CPUs) v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-eigenavx2-windows-x64.zip", "Eigen AVX2 (Modern CPUs) v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-eigenavx2-windows-x64.zip",
"Eigen (CPU, Non-optimized) v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-eigen-windows-x64.zip", "Eigen (CPU, Non-optimized) v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-eigen-windows-x64.zip",
"OpenCL v1.16.0 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-opencl-windows-x64+bs50.zip", "OpenCL v1.16.5 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-opencl-windows-x64+bs50.zip",
}, },
"linux": { "linux": {
"OpenCL v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-opencl-linux-x64.zip", "OpenCL v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-opencl-linux-x64.zip",
"Eigen AVX2 (Modern CPUs) v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-eigenavx2-linux-x64.zip", "Eigen AVX2 (Modern CPUs) v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-eigenavx2-linux-x64.zip",
"Eigen (CPU, Non-optimized) v1.16.0": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-eigen-linux-x64.zip", "Eigen (CPU, Non-optimized) v1.16.5": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-eigen-linux-x64.zip",
"OpenCL v1.16.0 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.16.0/katago-v1.16.0-opencl-linux-x64+bs50.zip", "OpenCL v1.16.5 (bigger boards)": "https://github.com/lightvector/KataGo/releases/download/v1.16.5/katago-v1.16.5-opencl-linux-x64+bs50.zip",
}, },
"just-descriptions": {}, "just-descriptions": {},
} }
def __init__(self, katrain): def __init__(self, katrain):
super().__init__(katrain) super().__init__(katrain)
self.paths = [self.katrain.config("engine/model"), self.katrain.config("engine/humanlike_model"), "katrain/models", DATA_FOLDER] self.paths = [
self.katrain.config("engine/model"),
self.katrain.config("engine/humanlike_model"),
"katrain/models",
DATA_FOLDER,
]
self.katago_paths = [self.katrain.config("engine/katago"), DATA_FOLDER] self.katago_paths = [self.katrain.config("engine/katago"), DATA_FOLDER]
self.last_clicked_download_models = 0 self.last_clicked_download_models = 0
@@ -561,7 +591,9 @@ class BaseConfigPopup(QuickConfigGui):
key=lambda descpath: ("Recommended" not in descpath[0], " - " not in descpath[0], descpath[0]), key=lambda descpath: ("Recommended" not in descpath[0], " - " not in descpath[0], descpath[0]),
) )
humanlike_models_available_msg = i18n._("models available").format(num=len(humanlike_model_files)) humanlike_models_available_msg = i18n._("models available").format(num=len(humanlike_model_files))
self.humanlike_model_files.values = [humanlike_models_available_msg] + [desc for desc, path in humanlike_model_files] self.humanlike_model_files.values = [humanlike_models_available_msg] + [
desc for desc, path in humanlike_model_files
]
self.humanlike_model_files.value_keys = [""] + [path for desc, path in humanlike_model_files] self.humanlike_model_files.value_keys = [""] + [path for desc, path in humanlike_model_files]
self.humanlike_model_files.text = humanlike_models_available_msg self.humanlike_model_files.text = humanlike_models_available_msg
@@ -650,7 +682,9 @@ class BaseConfigPopup(QuickConfigGui):
for name, url in {**self.MODELS, **dist_models}.items(): for name, url in {**self.MODELS, **dist_models}.items():
filename = os.path.split(url)[1] filename = os.path.split(url)[1]
if not any(os.path.split(f)[1] == filename for f in self.model_files.values + self.humanlike_model_files.values): if not any(
os.path.split(f)[1] == filename for f in self.model_files.values + self.humanlike_model_files.values
):
savepath = os.path.expanduser(os.path.join(DATA_FOLDER, filename)) savepath = os.path.expanduser(os.path.join(DATA_FOLDER, filename))
savepath_tmp = savepath + ".part" savepath_tmp = savepath + ".part"
self.katrain.log(f"Downloading {name} from {url} to {savepath_tmp}", OUTPUT_INFO) self.katrain.log(f"Downloading {name} from {url} to {savepath_tmp}", OUTPUT_INFO)
@@ -759,14 +793,28 @@ class BaseConfigPopup(QuickConfigGui):
class ConfigPopup(BaseConfigPopup): class ConfigPopup(BaseConfigPopup):
ENGINE_TAB_BUTTONS = {"local": "local_tab_button", "remote": "remote_tab_button", "custom": "custom_tab_button"}
def __init__(self, katrain): def __init__(self, katrain):
super().__init__(katrain) super().__init__(katrain)
Clock.schedule_once(self.check_katas) Clock.schedule_once(self.check_katas)
Clock.schedule_once(self.select_engine_tab)
MDApp.get_running_app().bind(language=self.check_models) MDApp.get_running_app().bind(language=self.check_models)
MDApp.get_running_app().bind(language=self.check_katas) MDApp.get_running_app().bind(language=self.check_katas)
def select_engine_tab(self, *_args):
# The active tab is authoritative for which engine is used; pick it based on the current config.
backend = resolve_engine_backend(self.katrain.config("engine"))
self.engine_sm.current = backend
getattr(self, self.ENGINE_TAB_BUTTONS[backend]).state = "down"
def update_config(self, save_to_file=True, close_popup=True): def update_config(self, save_to_file=True, close_popup=True):
old_backend = self.katrain.config("engine/backend", "")
backend = self.engine_sm.current
self.katrain._config["engine"]["backend"] = backend
updated = super().update_config(save_to_file=save_to_file, close_popup=close_popup) updated = super().update_config(save_to_file=save_to_file, close_popup=close_popup)
if backend != old_backend:
updated.add("engine/backend")
self.katrain.debug_level = self.katrain.config("general/debug_level", OUTPUT_INFO) self.katrain.debug_level = self.katrain.config("general/debug_level", OUTPUT_INFO)
ignore = {"max_visits", "fast_visits", "max_time", "enable_ownership", "wide_root_noise"} ignore = {"max_visits", "fast_visits", "max_time", "enable_ownership", "wide_root_noise"}
@@ -774,21 +822,8 @@ class ConfigPopup(BaseConfigPopup):
if detected_restart: if detected_restart:
def restart_engine(_dt): def restart_engine(_dt):
self.katrain.controls.set_status("", STATUS_INFO)
self.katrain.log(f"Restarting Engine after {detected_restart} settings change") self.katrain.log(f"Restarting Engine after {detected_restart} settings change")
self.katrain.controls.set_status(i18n._("restarting engine"), STATUS_INFO) self.katrain.restart_engine()
old_engine = self.katrain.engine # type: KataGoEngine
old_proc = old_engine.katago_process
if old_proc:
old_engine.shutdown(finish=False)
new_engine = KataGoEngine(self.katrain, self.katrain.config("engine"))
self.katrain.engine = new_engine
self.katrain.game.engines = {"B": new_engine, "W": new_engine}
self.katrain.game.analyze_all_nodes(
analyze_fast=True
) # old engine was possibly broken, so make sure we redo any failures
self.katrain.update_state()
Clock.schedule_once(restart_engine, 0) Clock.schedule_once(restart_engine, 0)
+45 -11
View File
@@ -1,9 +1,9 @@
from kivy.clock import Clock from kivy.clock import Clock
from kivymd.app import MDApp
from kivy.core.audio import SoundLoader from kivy.core.audio import SoundLoader
from kivy.utils import platform from kivy.utils import platform
cached_sounds = {} cached_sounds = {}
_audio_available = False
# prefer ffpyplayer on linux, then others, avoid gst and avoid or ffpyplayer on windows # prefer ffpyplayer on linux, then others, avoid gst and avoid or ffpyplayer on windows
ranking = [("ffplay", 98 if platform in ["win", "macosx"] else -2), ("sdl", -1), ("gst", 99), ("", 0)] ranking = [("ffplay", 98 if platform in ["win", "macosx"] else -2), ("sdl", -1), ("gst", 99), ("", 0)]
@@ -13,22 +13,56 @@ except Exception as e:
print("Exception sorting sound loaders: ", e) # private vars, so could break with versions etc print("Exception sorting sound loaders: ", e) # private vars, so could break with versions etc
def play_sound(file, volume=1, cache=True): def preload_sounds(sound_dir):
def _play(sound): """Preload all sounds before the SDL2 window is created.
if sound:
sound.play()
sound.seek(0)
SoundLoader.load() deadlocks when no audio output device is available,
so we test audio in a subprocess first.
"""
global _audio_available
import os
import sys
import subprocess
audio_files = [
os.path.join(sound_dir, fn) for fn in os.listdir(sound_dir)
if fn.endswith((".wav", ".ogg", ".mp3"))
]
if not audio_files:
return
# Test if audio subsystem works by loading one sound in a subprocess
try:
subprocess.run(
[sys.executable, "-c",
f"from kivy.core.audio import SoundLoader; SoundLoader.load({audio_files[0]!r})"],
timeout=3, capture_output=True,
)
except subprocess.TimeoutExpired as e:
print(f"Warning: Audio unavailable ({e}). Sounds disabled.", file=sys.stderr)
return
# Audio works, preload all sounds
for path in audio_files:
cached_sounds[os.path.basename(path)] = SoundLoader.load(path)
_audio_available = True
def play_sound(file, volume=1, cache=True):
if not _audio_available:
return
# the following import causes the creation of an SDL2 window, and if there
# is no sound device, it deadlocks. We delay the import until we know audio
# is available
from kivymd.app import MDApp
app = MDApp.get_running_app() app = MDApp.get_running_app()
if app and app.gui and app.gui.config("timer/sound"): if app and app.gui and app.gui.config("timer/sound"):
sound = cached_sounds.get(file) sound = cached_sounds.get(file)
if sound is None:
sound = SoundLoader.load(file)
if cache:
cached_sounds[file] = sound
if sound is not None: if sound is not None:
sound.volume = volume sound.volume = volume
Clock.schedule_once(lambda _dt: _play(sound), 0) sound.play()
sound.seek(0)
def stop_sound(file): def stop_sound(file):
Binary file not shown.
+55 -1
View File
@@ -872,7 +872,7 @@ msgstr "如果你还有任何困难,请参考疑难排解页面 {link}"
#. Link in tags should not be touched. #. Link in tags should not be touched.
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "首先,在 {link} 中,尝试下载且更换为不同版本的KataGo执行档是否能解决问题。" msgstr "请先点击「重试」以重新启动引擎。如果没有帮助,请尝试在 {link} 中下载 KataGo 版本并改用其他可执行文件。"
#. as in "click here" to go to this page, #. as in "click here" to go to this page,
msgid "link_here" msgid "link_here"
@@ -998,3 +998,57 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"与远程 KataGo 引擎的连接已断开,且无法自动重新建立。\n"
"\n"
"错误为:[color=#CC2222]{error_message}[/color]\n"
"\n"
"错误代码为:[color=#CC2222]{code}[/color]。"
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "重试"
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr "如果远程引擎之前能正常工作,请在其恢复后点击「重试」以重新连接。否则,请在 {link} 中再次检查远程引擎的网址。"
msgid "engine:tab:local"
msgstr "本地引擎"
msgid "engine:tab:remote"
msgstr "远程引擎"
msgid "engine:tab:custom"
msgstr "自定义命令"
msgid "engine:backend:note"
msgstr "所选标签页决定使用的引擎。"
Binary file not shown.
+60 -2
View File
@@ -940,8 +940,9 @@ msgstr ""
#. - Link in tags should not be touched. #. - Link in tags should not be touched.
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "" msgstr ""
"Versuchen sie erstmal KataGo Versionen runterzuladen und ob eine andere " "Drücken Sie zuerst auf Erneut versuchen, um die Engine neu zu starten. Falls"
"ausführbare Datei in {link} hilft. " " das nicht hilft, laden Sie KataGo-Versionen herunter und wechseln Sie in "
"{link} zu einer anderen ausführbaren Datei."
#. - as in "click here" to go to this page, #. - as in "click here" to go to this page,
msgid "link_here" msgid "link_here"
@@ -1067,3 +1068,60 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"Die Verbindung zur entfernten KataGo-Engine ging verloren und konnte nicht automatisch wiederhergestellt werden.\n"
"\n"
"Der Fehler war: [color=#CC2222]{error_message}[/color]\n"
"\n"
"Der Fehlercode war: [color=#CC2222]{code}[/color]."
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "Erneut versuchen"
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr ""
"Wenn die entfernte Engine zuvor funktioniert hat, drücken Sie auf Erneut "
"versuchen, um die Verbindung wiederherzustellen, sobald sie wieder verfügbar"
" ist. Andernfalls überprüfen Sie die URL der entfernten Engine in {link} ."
msgid "engine:tab:local"
msgstr "Lokale Engine"
msgid "engine:tab:remote"
msgstr "Remote-Engine"
msgid "engine:tab:custom"
msgstr "Eigener Befehl"
msgid "engine:backend:note"
msgstr "Der ausgewählte Reiter bestimmt die genutzte Engine."
Binary file not shown.
+52 -2
View File
@@ -653,6 +653,24 @@ msgstr ""
"Full command used to start the engine. All other model options will be " "Full command used to start the engine. All other model options will be "
"ignored." "ignored."
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
msgid "engine:max_visits" msgid "engine:max_visits"
msgstr "Maximum number of visits in analysis" msgstr "Maximum number of visits in analysis"
@@ -753,8 +771,28 @@ msgstr ""
#. Link in tags should not be touched. #. Link in tags should not be touched.
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "" msgstr ""
"First, try downloading KataGo versions and try if changing to a different " "First, press Retry to restart the engine. If that does not help, try "
"executable helps in {link} ." "downloading KataGo versions and changing to a different executable in {link}"
" ."
#. Shown instead of "engine died popup opening message" for remote engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"The connection to the remote KataGo engine was lost and could not be re-established automatically.\n"
"\n"
"The error was: [color=#CC2222]{error_message}[/color]\n"
"\n"
"The error code was: [color=#CC2222]{code}[/color]."
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr ""
"If the remote engine has worked before, press Retry to reconnect once it is "
"back. Otherwise, double-check the remote engine URL in {link} ."
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "Retry"
#. Link in tags should not be touched (uses link_here msgid) #. Link in tags should not be touched (uses link_here msgid)
msgid "go to engine help page" msgid "go to engine help page"
@@ -1073,3 +1111,15 @@ msgstr "Distance of wall"
#. tsumego: ko is good enough? #. tsumego: ko is good enough?
msgid "tsumego:ko" msgid "tsumego:ko"
msgstr "Ko allowed?" msgstr "Ko allowed?"
msgid "engine:tab:local"
msgstr "Local engine"
msgid "engine:tab:remote"
msgstr "Remote engine"
msgid "engine:tab:custom"
msgstr "Custom command"
msgid "engine:backend:note"
msgstr "The selected tab determines the engine used."
Binary file not shown.
+39 -3
View File
@@ -752,18 +752,18 @@ msgstr "Cache analysis in SGF"
msgid "menu:save-as" msgid "menu:save-as"
msgstr "Save Game As..." msgstr "Save Game As..."
#. TODO
# sgf load button # sgf load button
#. TODO
msgid "Load File" msgid "Load File"
msgstr "Load File" msgstr "Load File"
#. TODO
# sgf save popup # sgf save popup
#. TODO
msgid "save sgf title" msgid "save sgf title"
msgstr "Save SGF File" msgstr "Save SGF File"
#. TODO
# sgf save as button # sgf save as button
#. TODO
msgid "Save File" msgid "Save File"
msgstr "Save File" msgstr "Save File"
@@ -1029,3 +1029,39 @@ msgstr "Ko allowed?"
#. TODO - Menu option for tsumego frame around position #. TODO - Menu option for tsumego frame around position
msgid "analysis:tsumegoframe" msgid "analysis:tsumegoframe"
msgstr "Tsumego Frame" msgstr "Tsumego Frame"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
msgid "engine:tab:local"
msgstr "Motor local"
msgid "engine:tab:remote"
msgstr "Motor remoto"
msgid "engine:tab:custom"
msgstr "Comando personalizado"
msgid "engine:backend:note"
msgstr "La pestaña seleccionada determina el motor usado."
Binary file not shown.
+60 -2
View File
@@ -929,8 +929,9 @@ msgstr ""
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "" msgstr ""
"L'exécutable et le modèle KataGo utilisés doivent être compatibles. Vous " "Commencez par appuyer sur Réessayer pour redémarrer le moteur. Si cela ne "
"pouvez en télécharger et/ou en sélectionner d'autres dans les {link}. " "suffit pas, essayez de télécharger des versions de KataGo et de changer "
"d'exécutable dans {link} ."
msgid "link_here" msgid "link_here"
msgstr "ce lien" msgstr "ce lien"
@@ -1036,3 +1037,60 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"La connexion au moteur KataGo distant a été perdue et n'a pas pu être rétablie automatiquement.\n"
"\n"
"L'erreur était : [color=#CC2222]{error_message}[/color]\n"
"\n"
"Le code d'erreur était : [color=#CC2222]{code}[/color]."
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "Réessayer"
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr ""
"Si le moteur distant a déjà fonctionné, appuyez sur Réessayer pour vous "
"reconnecter une fois qu'il est de nouveau disponible. Sinon, vérifiez l'URL "
"du moteur distant dans {link} ."
msgid "engine:tab:local"
msgstr "Moteur local"
msgid "engine:tab:remote"
msgstr "Moteur distant"
msgid "engine:tab:custom"
msgstr "Commande personnalisée"
msgid "engine:backend:note"
msgstr "L'onglet sélectionné détermine le moteur utilisé."
Binary file not shown.
+59 -1
View File
@@ -916,7 +916,9 @@ msgstr "それでもだめなら、{link}して最新のトラブルシューテ
#. Link in tags should not be touched. #. Link in tags should not be touched.
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "まずは、{link}から別バージョンのKataGoをダウンロードしてうまくいくか試してください。" msgstr ""
"まず「再試行」を押してエンジンを再起動してください。解決しない場合は、{link} で KataGo "
"のバージョンをダウンロードし、別の実行ファイルに変更してみてください。"
#. as in "click here" to go to this page, #. as in "click here" to go to this page,
msgid "link_here" msgid "link_here"
@@ -1039,3 +1041,59 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"リモート KataGo エンジンへの接続が失われ、自動的に再接続できませんでした。\n"
"\n"
"エラー: [color=#CC2222]{error_message}[/color]\n"
"\n"
"エラーコード: [color=#CC2222]{code}[/color]。"
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "再試行"
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr ""
"リモートエンジンが以前は動作していた場合は、復旧後に「再試行」を押して再接続してください。そうでない場合は、{link} でリモートエンジンの URL "
"を再確認してください。"
msgid "engine:tab:local"
msgstr "ローカルエンジン"
msgid "engine:tab:remote"
msgstr "リモートエンジン"
msgid "engine:tab:custom"
msgstr "カスタムコマンド"
msgid "engine:backend:note"
msgstr "選択したタブが使用するエンジンを決定します。"
Binary file not shown.
+59 -1
View File
@@ -867,7 +867,9 @@ msgid "go to engine help page"
msgstr "아직 문제를 겪고 있으시다면, {link}에서 최신 문제 해결법을 찾아보십시오." msgstr "아직 문제를 겪고 있으시다면, {link}에서 최신 문제 해결법을 찾아보십시오."
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "우선, KataGo 버전들을 내려받아 보시고, {link}에서 다른 실행 파일로 바꾸면 괜찮아지는지 확인해 보십시오." msgstr ""
"먼저 다시 시도를 눌러 엔진을 다시 시작하세요. 그래도 해결되지 않으면 {link} 에서 KataGo 버전을 내려받아 다른 실행 파일로 "
"변경해 보세요."
msgid "link_here" msgid "link_here"
msgstr "여기" msgstr "여기"
@@ -966,3 +968,59 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"원격 KataGo 엔진과의 연결이 끊겼으며 자동으로 다시 연결하지 못했습니다.\n"
"\n"
"오류: [color=#CC2222]{error_message}[/color]\n"
"\n"
"오류 코드: [color=#CC2222]{code}[/color]."
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "다시 시도"
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr ""
"원격 엔진이 이전에 작동한 적이 있다면, 다시 사용 가능해졌을 때 다시 시도를 눌러 재연결하세요. 그렇지 않으면 {link} 에서 원격 "
"엔진 URL을 다시 확인하세요."
msgid "engine:tab:local"
msgstr "로컬 엔진"
msgid "engine:tab:remote"
msgstr "원격 엔진"
msgid "engine:tab:custom"
msgstr "사용자 지정 명령"
msgid "engine:backend:note"
msgstr "선택한 탭이 사용할 엔진을 결정합니다."
Binary file not shown.
@@ -1002,3 +1002,63 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. TODO - Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"The connection to the remote KataGo engine was lost and could not be re-established automatically.\n"
"\n"
"The error was: [color=#CC2222]{error_message}[/color]\n"
"\n"
"The error code was: [color=#CC2222]{code}[/color]."
#. TODO - Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "Retry"
#. TODO - Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr ""
"If the remote engine has worked before, press Retry to reconnect once it is "
"back. Otherwise, double-check the remote engine URL in {link} ."
#. TODO
msgid "engine:tab:custom"
msgstr "Custom command"
#. TODO
msgid "engine:backend:note"
msgstr "The selected tab determines the engine used."
#. TODO
msgid "engine:tab:remote"
msgstr "Remote engine"
#. TODO
msgid "engine:tab:local"
msgstr "Local engine"
Binary file not shown.
+60 -2
View File
@@ -733,8 +733,9 @@ msgstr ""
#. Link in tags should not be touched. #. Link in tags should not be touched.
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "" msgstr ""
"İlk önce, KataGo sürümlerini indirmeyi deneyin ve {link} bağlantısındaki " "Önce motoru yeniden başlatmak için Yeniden Dene'ye basın. Bu işe yaramazsa, "
"farklı yürütme dosyalarını kullanmayı deneyin." "{link} içinde KataGo sürümlerini indirip farklı bir yürütülebilir dosyaya "
"geçmeyi deneyin."
#. Link in tags should not be touched (uses link_here msgid) #. Link in tags should not be touched (uses link_here msgid)
msgid "go to engine help page" msgid "go to engine help page"
@@ -1102,3 +1103,60 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"Uzak KataGo motoruyla bağlantı kesildi ve otomatik olarak yeniden kurulamadı.\n"
"\n"
"Hata: [color=#CC2222]{error_message}[/color]\n"
"\n"
"Hata kodu: [color=#CC2222]{code}[/color]."
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "Yeniden Dene"
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr ""
"Uzak motor daha önce çalıştıysa, tekrar erişilebilir olduğunda yeniden "
"bağlanmak için Yeniden Dene'ye basın. Aksi takdirde, {link} içindeki uzak "
"motor URL'sini tekrar kontrol edin."
msgid "engine:tab:local"
msgstr "Yerel motor"
msgid "engine:tab:remote"
msgstr "Uzak motor"
msgid "engine:tab:custom"
msgstr "Özel komut"
msgid "engine:backend:note"
msgstr "Seçilen sekme, kullanılacak motoru belirler."
Binary file not shown.
+55 -1
View File
@@ -898,7 +898,7 @@ msgstr "如果你還有任何困難,請參考疑難排解頁面 {link}"
#. Link in tags should not be touched. #. Link in tags should not be touched.
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "首先,在 {link} 中,嘗試下載且更換為不同版本的KataGo執行檔是否能解決問題。" msgstr "請先按「重試」以重新啟動引擎。如果沒有幫助,請嘗試在 {link} 中下載 KataGo 版本並改用其他執行檔。"
#. as in "click here" to go to this page, #. as in "click here" to go to this page,
msgid "link_here" msgid "link_here"
@@ -1021,3 +1021,57 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"與遠端 KataGo 引擎的連線中斷,且無法自動重新建立。\n"
"\n"
"錯誤為:[color=#CC2222]{error_message}[/color]\n"
"\n"
"錯誤代碼為:[color=#CC2222]{code}[/color]。"
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "重試"
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr "如果遠端引擎先前曾正常運作,請在它恢復後按「重試」以重新連線。否則,請在 {link} 中再次確認遠端引擎的網址。"
msgid "engine:tab:local"
msgstr "本機引擎"
msgid "engine:tab:remote"
msgstr "遠端引擎"
msgid "engine:tab:custom"
msgstr "自訂命令"
msgid "engine:backend:note"
msgstr "所選分頁決定使用的引擎。"
Binary file not shown.
+60 -2
View File
@@ -751,8 +751,9 @@ msgstr ""
#. Link in tags should not be touched. #. Link in tags should not be touched.
msgid "change engine suggestion" msgid "change engine suggestion"
msgstr "" msgstr ""
"Спочатку спробуйте завантажити інші версії KataGo і перевірте, чи допоможе " "Спочатку натисніть «Повторити», щоб перезапустити рушій. Якщо це не "
"зміна виконуваного файлу в {link}." "допомогло, спробуйте завантажити версії KataGo та змінити виконуваний файл у"
" {link} ."
#. Link in tags should not be touched (uses link_here msgid) #. Link in tags should not be touched (uses link_here msgid)
msgid "go to engine help page" msgid "go to engine help page"
@@ -1086,3 +1087,60 @@ msgstr ""
#. TODO #. TODO
msgid "engine:humanlike_model" msgid "engine:humanlike_model"
msgstr "Path to human-like model" msgstr "Path to human-like model"
#. TODO
msgid "engine:remote_url"
msgstr "KataGo WebSocket URL"
#. TODO
msgid "engine:remote_url:hint"
msgstr "Leave blank to use a local KataGo engine. Use ws:// or wss://."
#. TODO
msgid "Remote KataGo URL is empty"
msgstr "Remote KataGo URL is empty"
#. TODO
msgid "Remote KataGo URL must start with ws:// or wss://"
msgstr "Remote KataGo URL must start with ws:// or wss://"
#. TODO
msgid "Connecting to remote KataGo failed"
msgstr "Connecting to remote KataGo at {url} failed: {error}"
#. TODO
msgid "Remote KataGo engine disconnected"
msgstr "Remote KataGo engine disconnected: {error}"
#. Shown instead of "engine died popup opening message" for remote
#. engines.
msgid "remote engine disconnected popup opening message"
msgstr ""
"З'єднання з віддаленим рушієм KataGo втрачено й не вдалося відновити автоматично.\n"
"\n"
"Помилка: [color=#CC2222]{error_message}[/color]\n"
"\n"
"Код помилки: [color=#CC2222]{code}[/color]."
#. Button to reconnect/restart the engine from the recovery popup.
msgid "engine recovery: retry"
msgstr "Повторити"
#. Link in tags should not be touched. Shown for remote engines.
msgid "remote engine check url suggestion"
msgstr ""
"Якщо віддалений рушій раніше працював, натисніть «Повторити», щоб "
"під'єднатися знову, коли він стане доступним. Інакше перевірте URL-адресу "
"віддаленого рушія в {link} ."
msgid "engine:tab:local"
msgstr "Локальний рушій"
msgid "engine:tab:remote"
msgstr "Віддалений рушій"
msgid "engine:tab:custom"
msgstr "Власна команда"
msgid "engine:backend:note"
msgstr "Вибрана вкладка визначає використовуваний рушій."
+201 -105
View File
@@ -3,9 +3,11 @@
#:import expanduser os.path.expanduser #:import expanduser os.path.expanduser
#:import abspath os.path.abspath #:import abspath os.path.abspath
#:import kivy_version kivy.__version__ #:import kivy_version kivy.__version__
#:import NoTransition kivy.uix.screenmanager.NoTransition
#:import Theme katrain.gui.theme.Theme #:import Theme katrain.gui.theme.Theme
#:import LIGHT_GREY katrain.gui.theme.LIGHT_GREY #:import LIGHT_GREY katrain.gui.theme.LIGHT_GREY
#:import ORANGE katrain.gui.theme.ORANGE
#:set CP_SPACING Theme.CP_SPACING #:set CP_SPACING Theme.CP_SPACING
#:set CP_SMALL_SPACING Theme.CP_SMALL_SPACING #:set CP_SMALL_SPACING Theme.CP_SMALL_SPACING
@@ -92,131 +94,216 @@
katago_files: katago_files katago_files: katago_files
download_progress_box: download_progress_box download_progress_box: download_progress_box
katago_download_progress_box: katago_download_progress_box katago_download_progress_box: katago_download_progress_box
engine_sm: engine_sm
local_tab_button: local_tab_button
remote_tab_button: remote_tab_button
custom_tab_button: custom_tab_button
# Top Section - KataGo Controls # Top Section - Engine source header
BoxLayout: BoxLayout:
orientation: 'horizontal' orientation: 'horizontal'
size_hint_y: None size_hint_y: None
height: dp(50) height: dp(36)
spacing: dp(20) padding: dp(10), 0
padding: dp(10)
DescriptionLabel: DescriptionLabel:
text: i18n._('katago settings') text: i18n._('katago settings')
font_size: sp(Theme.DESC_FONT_SIZE) * 1.5 font_size: sp(Theme.DESC_FONT_SIZE) * 1.5
size_hint_x: 0.4 halign: 'left'
AutoSizedRoundedRectangleButton: # Engine backend tabs, with the "tab is authoritative" note to their right
text: i18n._("download katago button")
on_press: root.download_katas()
size_hint: (0.3, 0.8)
AutoSizedRoundedRectangleButton:
text: i18n._("download models button")
on_press: root.download_models()
size_hint: (0.3, 0.8)
# Main Content Area
MDBoxLayout: MDBoxLayout:
orientation: 'vertical' orientation: 'horizontal'
size_hint_y: None
height: dp(40)
spacing: dp(6)
padding: dp(8), 0
SizedRectangleToggleButton:
id: local_tab_button
size_hint: 0.22, 1
group: 'engine_backend'
allow_no_selection: False
text: i18n._('engine:tab:local')
on_release: engine_sm.current = 'local'
SizedRectangleToggleButton:
id: remote_tab_button
size_hint: 0.22, 1
group: 'engine_backend'
allow_no_selection: False
text: i18n._('engine:tab:remote')
on_release: engine_sm.current = 'remote'
SizedRectangleToggleButton:
id: custom_tab_button
size_hint: 0.22, 1
group: 'engine_backend'
allow_no_selection: False
text: i18n._('engine:tab:custom')
on_release: engine_sm.current = 'custom'
DescriptionLabel:
size_hint_x: 0.4
text: i18n._('engine:backend:note')
color: ORANGE
halign: 'left'
valign: 'center'
padding: dp(10), 0
# Engine backend content - one screen per tab
ScreenManager:
id: engine_sm
size_hint: (1, 6) size_hint: (1, 6)
spacing: dp(15) transition: NoTransition()
padding: dp(10)
# Model Section Screen:
ConfigSectionGridLayout: name: 'local'
cols: 2 MDBoxLayout:
DescriptionLabel: orientation: 'vertical'
text: i18n._("engine:model") spacing: dp(12)
size_hint: (0.33, 1) padding: dp(10)
AnchorLayout: # KataGo executable - download button sits under its label
LabelledPathInput: ConfigSectionGridLayout:
id: model_path cols: 2
input_property: "engine/model" MDBoxLayout:
on_text: root.check_models() orientation: 'vertical'
size_hint_x: 0.33
spacing: dp(6)
DescriptionLabel:
text: i18n._("engine:katago")
AnchorLayout:
size_hint_y: None
height: dp(34)
AutoSizedRoundedRectangleButton:
text: i18n._("download katago button")
on_press: root.download_katas()
size_hint: None, None
height: dp(32)
MDBoxLayout:
orientation: 'vertical'
AnchorLayout:
LabelledPathInput:
id: katago_path
input_property: "engine/katago"
hint_text: i18n._("engine:katago:hint")
on_text: root.check_katas()
AnchorLayout:
KeyValueSpinner:
id: katago_files
text: ''
text_autoupdate: True
size_hint_y: 0.7
on_select: if self.selected_index != 0: katago_path.text = self.value_keys[self.selected_index]
Label: # Model - download button sits under its label
size_hint: (0.33, 1) ConfigSectionGridLayout:
cols: 2
MDBoxLayout:
orientation: 'vertical'
size_hint_x: 0.33
spacing: dp(6)
DescriptionLabel:
text: i18n._("engine:model")
AnchorLayout:
size_hint_y: None
height: dp(34)
AutoSizedRoundedRectangleButton:
text: i18n._("download models button")
on_press: root.download_models()
size_hint: None, None
height: dp(32)
MDBoxLayout:
orientation: 'vertical'
AnchorLayout:
LabelledPathInput:
id: model_path
input_property: "engine/model"
on_text: root.check_models()
AnchorLayout:
KeyValueSpinner:
id: model_files
text: ''
text_autoupdate: True
size_hint_y: 0.7
on_select: if self.selected_index != 0: model_path.text = self.value_keys[self.selected_index]
AnchorLayout: # Human-like model
KeyValueSpinner: ConfigSectionGridLayout:
id: model_files cols: 2
text: '' DescriptionLabel:
text_autoupdate: True text: i18n._("engine:humanlike_model")
size_hint_y: 0.7 size_hint_x: 0.33
on_select: if self.selected_index != 0: model_path.text = self.value_keys[self.selected_index] MDBoxLayout:
orientation: 'vertical'
AnchorLayout:
LabelledPathInput:
id: humanlike_model_path
input_property: "engine/humanlike_model"
on_text: root.check_models()
AnchorLayout:
KeyValueSpinner:
id: humanlike_model_files
text: ''
text_autoupdate: True
size_hint_y: 0.7
on_select: if self.selected_index != 0: humanlike_model_path.text = self.value_keys[self.selected_index]
# Humanlike Model Section # KataGo config file
ConfigSectionGridLayout: ConfigSectionGridLayout:
cols: 2 cols: 2
DescriptionLabel: DescriptionLabel:
text: i18n._("engine:humanlike_model") text: i18n._("engine:config")
size_hint: (0.33, 1) size_hint_x: 0.33
AnchorLayout:
LabelledPathInput:
input_property: "engine/config"
AnchorLayout: Screen:
LabelledPathInput: name: 'remote'
id: humanlike_model_path MDBoxLayout:
input_property: "engine/humanlike_model" orientation: 'vertical'
on_text: root.check_models() spacing: dp(12)
padding: dp(10)
ConfigSectionGridLayout:
cols: 2
size_hint_y: None
height: dp(70)
DescriptionLabel:
text: i18n._("engine:remote_url")
size_hint_x: 0.33
AnchorLayout:
LabelledPathInput:
check_path: False
input_property: "engine/remote_url"
hint_text: i18n._("engine:remote_url:hint")
Widget:
Label: Screen:
size_hint: (0.33, 1) name: 'custom'
MDBoxLayout:
AnchorLayout: orientation: 'vertical'
KeyValueSpinner: spacing: dp(12)
id: humanlike_model_files padding: dp(10)
text: '' ConfigSectionGridLayout:
text_autoupdate: True cols: 2
size_hint_y: 0.7 size_hint_y: None
on_select: if self.selected_index != 0: humanlike_model_path.text = self.value_keys[self.selected_index] height: dp(70)
DescriptionLabel:
# KataGo Engine Section text: i18n._("engine:altcommand")
ConfigSectionGridLayout: size_hint_x: 0.33
cols: 2 AnchorLayout:
DescriptionLabel: LabelledPathInput:
text: i18n._("engine:katago") check_path: False
size_hint: (0.33, 1) input_property: "engine/altcommand"
hint_text: i18n._("engine:altcommand:hint")
AnchorLayout: Widget:
LabelledPathInput:
id: katago_path
input_property: "engine/katago"
hint_text: i18n._("engine:katago:hint")
on_text: root.check_katas()
Label:
size_hint: (0.33, 1)
AnchorLayout:
KeyValueSpinner:
id: katago_files
text: ''
text_autoupdate: True
size_hint_y: 0.7
on_select: if self.selected_index != 0: katago_path.text = self.value_keys[self.selected_index]
ConfigSectionGridLayout:
rows: 2
cols: 2
DescriptionLabel:
text: i18n._("engine:config")
size_hint: 0.33, 1
AnchorLayout:
LabelledPathInput:
input_property: "engine/config"
DescriptionLabel:
text: i18n._("engine:altcommand")
size_hint: 0.33, 1
AnchorLayout:
LabelledPathInput:
check_path: False
input_property: "engine/altcommand"
hint_text: i18n._("engine:altcommand:hint")
# Settings Headers # Settings Headers
MDBoxLayout: MDBoxLayout:
size_hint_y: 0.5 size_hint_y: None
height: dp(34)
orientation: 'horizontal' orientation: 'horizontal'
padding: dp(10) padding: dp(10)
spacing: dp(20) spacing: dp(20)
@@ -874,14 +961,23 @@
on_press: root.update_config(True) on_press: root.update_config(True)
<EngineRecoveryPopup>: <EngineRecoveryPopup>:
orientation: 'vertical'
Label: Label:
size_hint: 1, 0.85
padding: CP_PADDING, CP_PADDING*3 padding: CP_PADDING, CP_PADDING*3
text: i18n._("engine died popup opening message").format(code=root.code,error_message=root.error_message) + "\n\n" + i18n._("change engine suggestion").format(link="[color=#CCCC11][u][ref=engine_settings]"+i18n._('menu:settings')+"[/ref][/u][/color]") + "\n\n" + i18n._("go to engine help page").format(link="[color=#CCCC11][u][ref=engine_help]"+i18n._('link_here')+"[/ref][/u][/color]") text: root.recovery_message
text_size: self.size text_size: self.size
font_size: sp(Theme.DESC_FONT_SIZE) font_size: sp(Theme.DESC_FONT_SIZE)
markup: True markup: True
valign: 'top' valign: 'top'
on_ref_press: app.webbrowser('engine:help') if args[1]=='engine_help' else app.gui('config-popup') on_ref_press: app.webbrowser('engine:help') if args[1]=='engine_help' else app.gui('config-popup')
AnchorLayout:
size_hint: 1, 0.15
AutoSizedRoundedRectangleButton:
padding_x: 15
size_hint: None, 0.66
text: i18n._("engine recovery: retry")
on_release: root.retry()
+3 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "KaTrain" name = "KaTrain"
version = "1.17.1" version = "1.18.0"
description = "Go/Baduk/Weiqi playing and teaching app with a variety of AIs" description = "Go/Baduk/Weiqi playing and teaching app with a variety of AIs"
authors = [{ name = "Sander Land" }] authors = [{ name = "Sander Land" }]
requires-python = ">=3.9,<3.14" requires-python = ">=3.9,<3.14"
@@ -22,6 +22,8 @@ dependencies = [
"urllib3>=2.2.2", "urllib3>=2.2.2",
"kivy>=2.3.1", "kivy>=2.3.1",
"kivymd==0.104.1", "kivymd==0.104.1",
"websocket-client>=1.9.0",
"certifi>=2026.2.25",
] ]
[project.urls] [project.urls]
+231
View File
@@ -0,0 +1,231 @@
"""Tests for the remote (WebSocket) KataGo engine's reconnect logic.
These use an in-process fake WebSocket so no network/server is needed:
`create_connection` is monkeypatched to hand out controllable sockets
whose recv()/send()/close() we drive from the test thread.
"""
import queue
import threading
import time
import pytest
from websocket import WebSocketException
from katrain.core import remote_engine
from katrain.core.remote_engine import RemoteKataGoEngine
class FakeWS:
"""Minimal stand-in for a websocket-client connection."""
def __init__(self):
self.sent = []
self._recv = queue.Queue()
self.closed = False
def recv_data(self, control_frame=True):
item = self._recv.get() # blocks like a real recv
if isinstance(item, BaseException):
raise item
return item
def send(self, payload):
if self.closed:
raise WebSocketException("send on closed socket")
self.sent.append(payload)
def close(self):
self.closed = True
self._recv.put(WebSocketException("closed"))
def drop(self):
"""Simulate the server/network dropping the connection."""
self._recv.put(WebSocketException("connection lost"))
class FakeControls:
def set_status(self, *args, **kwargs):
pass
class FakeKatrain:
"""Records calls (e.g. the engine_recovery_popup trigger)."""
def __init__(self):
self.controls = FakeControls()
self.calls = []
self.update_state = lambda *a, **k: None
def log(self, *args, **kwargs):
pass
def __call__(self, *args, **kwargs):
self.calls.append(args)
def wait_until(pred, timeout=5.0, interval=0.02):
end = time.time() + timeout
while time.time() < end and not pred():
time.sleep(interval)
return pred()
def popup_codes(katrain):
return [args[2] for args in katrain.calls if args and args[0] == "engine_recovery_popup"]
@pytest.fixture
def fast_backoff(monkeypatch):
monkeypatch.setattr(RemoteKataGoEngine, "RECONNECT_BACKOFF_S", 0.01)
monkeypatch.setattr(RemoteKataGoEngine, "RECONNECT_MAX_BACKOFF_S", 0.02)
def test_reconnect_resends_outstanding_queries(monkeypatch, fast_backoff):
created = []
def factory(*args, **kwargs):
ws = FakeWS()
created.append(ws)
return ws
monkeypatch.setattr(remote_engine, "create_connection", factory)
katrain = FakeKatrain()
engine = RemoteKataGoEngine(katrain, {"remote_url": "ws://test", "allow_recovery": True})
try:
assert wait_until(lambda: len(created) == 1)
ws1 = created[0]
engine.send_query({"foo": "bar"}, lambda *a: None, None)
assert wait_until(lambda: len(ws1.sent) == 1)
assert wait_until(lambda: len(engine.queries) == 1)
# Connection drops while a query is outstanding.
ws1.drop()
# A new connection is established transparently...
assert wait_until(lambda: len(created) == 2)
ws2 = created[1]
assert wait_until(lambda: engine.ws is ws2)
# ...and the in-flight query is re-sent on it.
assert wait_until(lambda: len(ws2.sent) >= 1)
# No local-engine recovery popup was shown.
assert popup_codes(katrain) == []
finally:
engine.shutdown()
def test_reconnect_failure_opens_recovery_popup(monkeypatch, fast_backoff):
monkeypatch.setattr(RemoteKataGoEngine, "RECONNECT_ATTEMPTS", 2)
first = FakeWS()
n = {"count": 0}
def factory(*args, **kwargs):
n["count"] += 1
if n["count"] == 1:
return first
raise WebSocketException("connection refused")
monkeypatch.setattr(remote_engine, "create_connection", factory)
katrain = FakeKatrain()
engine = RemoteKataGoEngine(katrain, {"remote_url": "ws://test", "allow_recovery": True})
try:
assert wait_until(lambda: engine.ws is first)
first.drop()
# All reconnect attempts fail -> fall back to the recovery popup,
# tagged as a remote disconnect rather than a local crash.
assert wait_until(lambda: popup_codes(katrain) == ["REMOTE-DISCONNECTED"])
# The popup is told this is a remote engine so it shows remote-
# specific advice (check URL) instead of the local executable hints.
popup_call = next(args for args in katrain.calls if args and args[0] == "engine_recovery_popup")
assert popup_call[3] == "remote"
finally:
engine.shutdown()
def test_check_alive_polling_during_reconnect_does_not_suppress_popup(monkeypatch, fast_backoff):
# Reproduces the ai.py move loops, which spin on check_alive(exception_if_dead=True)
# every ~10ms. While reconnecting the socket is briefly None; those polls must not
# latch the engine "dead" and steal the recovery popup that fires on real failure.
monkeypatch.setattr(RemoteKataGoEngine, "RECONNECT_ATTEMPTS", 4)
first = FakeWS()
n = {"count": 0}
def factory(*args, **kwargs):
n["count"] += 1
if n["count"] == 1:
return first
raise WebSocketException("connection refused")
monkeypatch.setattr(remote_engine, "create_connection", factory)
katrain = FakeKatrain()
engine = RemoteKataGoEngine(katrain, {"remote_url": "ws://test", "allow_recovery": True})
stop = threading.Event()
seen_alive_while_reconnecting = {"v": False}
def poller():
while not stop.is_set():
alive = engine.check_alive(exception_if_dead=True)
if engine._reconnecting and alive:
seen_alive_while_reconnecting["v"] = True
time.sleep(0.001)
t = threading.Thread(target=poller, daemon=True)
try:
assert wait_until(lambda: engine.ws is first)
t.start()
first.drop()
# Despite aggressive polling, the popup still fires exactly once.
assert wait_until(lambda: popup_codes(katrain) == ["REMOTE-DISCONNECTED"])
time.sleep(0.05) # let the poller keep hammering after the report
assert popup_codes(katrain) == ["REMOTE-DISCONNECTED"]
# And check_alive reported "alive" during the reconnect window, not dead.
assert seen_alive_while_reconnecting["v"]
finally:
stop.set()
t.join(timeout=1)
engine.shutdown()
def test_new_game_clears_resend_backlog(monkeypatch, fast_backoff):
created = []
def factory(*args, **kwargs):
ws = FakeWS()
created.append(ws)
return ws
monkeypatch.setattr(remote_engine, "create_connection", factory)
katrain = FakeKatrain()
engine = RemoteKataGoEngine(katrain, {"remote_url": "ws://test", "allow_recovery": True})
try:
ws1 = created[0]
engine.send_query({"foo": "bar"}, lambda *a: None, None)
assert wait_until(lambda: len(engine.sent_payloads) == 1)
# Starting a new game drops outstanding queries; they must not be
# resurrected by a later reconnect.
engine.on_new_game()
assert engine.sent_payloads == {}
ws1.drop()
assert wait_until(lambda: len(created) == 2)
ws2 = created[1]
assert wait_until(lambda: engine.ws is ws2)
# The previous game's analysis query is not resurrected (a leftover
# `terminate` command queued by on_new_game may still be sent).
time.sleep(0.1)
assert all("foo" not in payload for payload in ws2.sent)
finally:
engine.shutdown()
Generated
+468 -287
View File
File diff suppressed because it is too large. Load diff