bool: + return Version(local) < Version(remote) + + +def is_newer_version(local: str, remote: str) -> bool: + return Version(local) > Version(remote) + + +def get_pefile_path(path: str, appid: int) -> Path: + binary = "DayZ_x64.exe" + identifier = {221100: "DayZ", 1024020: "DayZ Exp"} + name = identifier[appid] + + pe_path = None + path = path + "/steamapps/libraryfolders.vdf" + + with open(path, "r") as f: + try: + j = json.loads(vdf_to_json(f)) + except Exception: + raise VDFLoadError("Failed to parse libraryfolders") + + for obj in j["libraryfolders"]: + if str(appid) in j["libraryfolders"][obj]["apps"]: + pe_path = j["libraryfolders"][obj]["path"] + pe_path += f"/steamapps/common/{name}/{binary}" + break + + if pe_path is None: + raise AppNotInstalledError( + f"Failed to find a libraryfolder for the appid '{appid}'" + ) + + pe_path = Path(pe_path) + if pe_path.exists() is False: + raise AppMovedError( + f"Path '{pe_path}' specified in libraryfolders does not exist" + ) + + return pe_path + + +def compare_versions(remote: str, appid: int, path: str): + if appid == 221100: + build = "DayZ" + else: + build = "DayZ Experimental" + + local = None + pe_filepath = None + error = None + + try: + pe_filepath = get_pefile_path(path, appid) + except Exception as e: + return Result( + local, remote, build, pe_filepath, VersionMatch.FAIL, e + ) + + try: + local = get_dayz_version_str(pe_filepath) + except PeFileError: + return Result( + local, remote, build, pe_filepath, VersionMatch.FAIL, error + ) + + if is_older_version(local, remote): + res = VersionMatch.LOCAL_OLDER + elif is_newer_version(local, remote): + res = VersionMatch.LOCAL_NEWER + else: + res = VersionMatch.SAME_VERSION + + return Result(local, remote, build, pe_filepath, res, error) + + +def vdf_to_json(stream): + def _istr(indent, string): + return (indent * ' ') + string + + jbuf = '{\n' + lex = shlex(stream) + indent = 1 + + while True: + tok = lex.get_token() + if not tok: + return jbuf + '}\n' + if tok == '}': + indent -= 1 + jbuf += _istr(indent, '}') + ntok = lex.get_token() + lex.push_token(ntok) + if ntok and ntok != '}': + jbuf += ',' + jbuf += '\n' + else: + ntok = lex.get_token() + if ntok == '{': + jbuf += _istr(indent, tok + ': {\n') + indent += 1 + else: + jbuf += _istr(indent, tok + ': ' + ntok) + ntok = lex.get_token() + lex.push_token(ntok) + if ntok != '}': + jbuf += ',' + jbuf += '\n' diff --git a/helpers/servers.py b/helpers/servers.py index 5636710..565ad0a 100644 --- a/helpers/servers.py +++ b/helpers/servers.py @@ -29,6 +29,18 @@ params = [ ] +class BmAPIError(Exception): + pass + + +class BmIdError(Exception): + pass + + +class InvalidIpError(Exception): + pass + + def get_netmask() -> str: hostname = os.uname()[1] i = socket.gethostbyname(hostname) @@ -196,7 +208,7 @@ class Res: json: Union[str, None] -@dataclass +@dataclass(slots=True, frozen=True) class Ping: addr: str iteration: int @@ -210,17 +222,33 @@ class Details: success: bool -def is_passworded(ip: str, qport: int) -> bool: +@dataclass +class Prereqs: + password: bool + gameport: int + appid: Union[int, None] + version: Union[str, None] + + +@dataclass(slots=True) +class Record: + ip: str + gameport: int + qport: int + + +def get_prereqs(ip: str, qport: int) -> Prereqs: try: info = a2s.info((ip, qport)) except TimeoutError: - return False + return Prereqs(False, 0, None, None) - try: - password = info.password_protected - except AttributeError: - return False - return password + gameport = getattr(info, "port", 0) + is_password = getattr(info, "password_protected", False) + appid = getattr(info, "game_id", None) + version = getattr(info, "version", None) + + return Prereqs(is_password, gameport, appid, version) def details(ip: str, qport: int) -> Details: @@ -343,12 +371,12 @@ def ping(iteration: int, row: list) -> Ping: return Ping(addr, iteration, ping) -def query_api(key: str, param: str) -> Res: +def query_api(key: str, appid: int, param: str) -> Res: LIMIT = 10000 url = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?" payload: dict[str, Union[int, str]] = { - "filter": r"\appid\221100" + param, + "filter": r"\appid" + fr"\{appid}" + param, "limit": LIMIT, "key": key, } @@ -375,3 +403,62 @@ def query_api(key: str, param: str) -> Res: data = None finally: return Res(status, parsed, data) + + +def query_bm_api(api_key: str, bm_id: str) -> Record: + if bm_id.isnumeric() is False: + raise BmIdError("ID must be numeric only") + + payload: dict[str, Union[int, str]] = { + "sort": "-players", + "filter[game]": "dayz", + "filter[ids][whitelist]": bm_id, + } + + url = "https://api.battlemetrics.com/servers?" + par = parse.urlencode(payload) + url = f"{url}{par}" + + hdr = {"Authorization": "Bearer " + api_key} + r = request.Request(url, headers=hdr) + + try: + with request.urlopen(r) as response: + try: + j = json.load(response) + except json.decoder.JSONDecodeError: + raise BmAPIError("Malformed response from Battlemetrics") + + if len(j["data"]) < 1: + raise BmAPIError("Not a valid Battlemetrics ID") + j = j["data"][0]["attributes"] + return Record(j["ip"], j["port"], j["portQuery"]) + except HTTPError: + raise BmAPIError("Failed to query Battlemetrics") + + +def validate_ip(addr: str): + fields = addr.split(":") + if len(fields) != 2: + raise InvalidIpError("Address must be formatted as IP:Queryport") + + ip = fields[0] + port = fields[1] + + try: + int(port) + except ValueError: + raise InvalidIpError(f"'{port}' is not a valid port") + + if int(port) > 65535 or int(port) < 0: + raise InvalidIpError(f"'{port}' is not a valid port") + + try: + socket.inet_aton(ip) + except OSError: + raise InvalidIpError(f"'{ip}' is not a valid IP") + + ip = addr.split(":")[0] + qport = int(addr.split(":")[1]) + record = Record(ip, 0, qport) + return record diff --git a/helpers/ui.py b/helpers/ui.py index 02022a6..0c3f33b 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -13,21 +13,24 @@ import threading import typing # noqa import warnings +from dataclasses import dataclass from enum import Enum +from collections.abc import Callable from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor - -from collections.abc import Callable from typing import Literal, Self, Any import servers as Servers # noqa E402 +import pefile as PeFile # noqa E402 + +from pefile import VDFLoadError, AppNotInstalledError, AppMovedError, PeFileError +from pefile import VersionMatch locale.setlocale(locale.LC_ALL, "") import gi # noqa E402 - gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa: E402 +from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 # https://bugzilla.gnome.org/show_bug.cgi?id=708676 warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) @@ -37,6 +40,9 @@ app_name_lower = app_name.lower() app_name_abbr = "dzg" delimiter = "␞" +APPID_DAYZ = 221100 +APPID_DAYZ_EXP = 1024020 + cache: dict[str, int] = {} config_vals: list[str] = [] @@ -87,6 +93,13 @@ If this issue persists, your API key may be defunct. """ +@dataclass +class Record: + ip: str + gameport: int + qport: int + + class Preferences(Enum): STEAM = 1 BM = 2 @@ -349,6 +362,12 @@ class RowType(EnumWithAttrs): "wait_msg": "Waiting for DayZ", "type": Command.ONESHOT, } + HANDSHAKE_EXP = { + "label": "Handshake_EXP", + "tooltip": None, + "wait_msg": "Waiting for DayZ", + "type": Command.ONESHOT, + } DELETE_SELECTED = { "label": "Delete selected mods", "tooltip": None, @@ -666,6 +685,7 @@ def save_res_and_quit(*args) -> None: def suppress_signal( owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool ) -> None: + func = getattr(owner, func_name) if state: logger.debug(f"Blocking {func_name} for {widget}") @@ -705,6 +725,7 @@ def format_metadata(row_sel: str) -> str: "fav_label": config_vals[4], "preferred_client": config_vals[5], "fullscreen": config_vals[6], + "default_steam_path": config_vals[7] } if row is None: return "" @@ -899,6 +920,11 @@ def process_shell_return_code( if final_conf == 1 or final_conf is None: return process_tree_option(RowType.HANDSHAKE) + case 101: # final handshake, exp + final_conf = spawn_dialog(msg, Popup.CONFIRM) + if final_conf == 1 or final_conf is None: + return + process_tree_option(RowType.HANDSHAKE_EXP) case 255: # dzgui version update msg = "Update complete. Please close DZGUI and restart." spawn_dialog(msg, Popup.QUIT) @@ -973,6 +999,22 @@ def process_tree_option(choice: RowType) -> None: App.grid.notebook.set_page_by_enum(NotebookPage.CHANGELOG) return + if command == RowType.QUICK_CONNECT: + record = query_config("fav_server")[0] + if record == "": + spawn_dialog("No favorite server currently set", Popup.NOTIFY) + return + + record = str_to_record(record) + thread_new_with_dialog( + App.treeview.prepare_connection, + parse_shell_output, + "Querying server", + command, + [record] + ) + return + match command.dict["type"]: case Command.HELP: call_bash_func("Open link", cmd_string) @@ -989,6 +1031,61 @@ def process_tree_option(choice: RowType) -> None: return +def parse_shell_output(proc: subprocess.CompletedProcess, row: RowType): + out = proc.stdout.splitlines() + try: + msg = out[-1] + except IndexError: + msg = "" + process_shell_return_code(msg, proc.returncode, row) + + +def thread_new_with_dialog( + func: Callable, + callback: Callable | None, + msg: str, + row: RowType | None, + args: list +) -> None: + + """ + Pop a GenericDialog transient to App.treeview and + call a function on a thread, with optional callback. + Chiefly used for connection-related subprocesses. + + After completion, the dialog is destroyed in the main event loop + and additional exception handling occurs. + + This is intended as a bridge between legacy shell methods and the UI. + A more abstracted version of call_on_thread() for when extra threaded + processing occurs before calls to shell subprocesses. + """ + + def background(*args): + def cleanup(): + App.treeview.dialog_hide() + if exception is not None: + spawn_dialog(str(exception), Popup.NOTIFY) + process_user_input(row) + return + if callback is not None and proc is not None: + callback(proc, row) + + exception = None + proc = None + try: + proc = func(*args) + except Exception as e: + exception = e + GLib.idle_add(cleanup) + return + GLib.idle_add(cleanup) + + App.treeview.dialog_show(msg) + thread = threading.Thread(target=background, args=(args)) + thread.start() + + def process_toggle(command: RowType) -> None: cmd_string = command.dict["label"] match command: @@ -1013,11 +1110,60 @@ def process_toggle(command: RowType) -> None: proc = call_out("toggle", cmd_string) +def str_to_record(record: str) -> Record | None: + r = record.split(":") + if len(r) != 3: + return None + return Record(r[0], int(r[1]), int(r[2])) + + +def record_to_str(record: Record) -> str: + return f"{record.ip}:{record.gameport}:{record.qport}" + + +def connect_by_ip(enum: RowType, response: str) -> None: + def _prep(response: str) -> None: + record = Servers.validate_ip(response) + proc = App.treeview.prepare_connection(record) + return proc + + thread_new_with_dialog( + _prep, + parse_shell_output, + "Querying IP", + enum, + [response] + ) + return + + +def connect_by_id(enum: RowType, response: str, key: str) -> None: + def _prep(key: str, response: str) -> None: + record = Servers.query_bm_api(key, response) + proc = App.treeview.prepare_connection(record) + return proc + + thread_new_with_dialog( + _prep, + parse_shell_output, + "Querying API", + enum, + [key, response] + ) + return + + def process_user_input(enum: RowType) -> None: prompt = enum.dict["prompt"] link_label = enum.dict["link_label"] cmd_string = enum.dict["label"] + if enum == RowType.CONN_BY_ID: + key = query_config("api_key")[0] + if len(key) == 0: + spawn_dialog("No Battlemetrics API key is set; see Options", Popup.NOTIFY) + return + user_entry = EntryDialog(prompt, Popup.ENTRY, link_label) response = user_entry.get_input() @@ -1026,6 +1172,14 @@ def process_user_input(enum: RowType) -> None: return logger.info(f"User entered: '{response}'") + if enum == RowType.CONN_BY_IP: + connect_by_ip(enum, response) + return + + if enum == RowType.CONN_BY_ID: + connect_by_id(enum, response, key) + return + show_wait_dialog = True wait_msg = "Working" call_on_thread( @@ -1789,7 +1943,7 @@ class TreeView(Gtk.TreeView): it = self.get_current_iter() name = model.get_value(it, 0) record = self.get_record_dict() - DetailsDialog(name, record["ip"], record["qport"]) + DetailsDialog(name, record.ip, record.qport) def show_mods(self) -> None: record = self.get_record_string() @@ -1976,7 +2130,7 @@ class TreeView(Gtk.TreeView): if not record: grid.statusbar.update_server_meta() return - ip = record["ip"] + ip = record.ip if ip in cache: km = cache[ip] grid.statusbar.append_distance(km) @@ -2135,8 +2289,8 @@ class TreeView(Gtk.TreeView): addr = model[path][7] qport = model[path][8] ip = addr.split(":")[0] - qport = str(qport) - return {"ip": ip, "qport": qport} + gameport = int(addr.split(":")[1]) + return Record(ip, gameport, qport) def update_players(self, players: int) -> None: model = self.get_model() @@ -2183,9 +2337,7 @@ class TreeView(Gtk.TreeView): record = self.get_record_dict() if not record: return - ip = record["ip"] - qport = record["qport"] - data = call_out("get_player_count", ip, qport) + data = call_out("get_player_count", record.ip, str(record.qport)) if data.returncode == 1: wait_dialog.destroy() return @@ -2195,10 +2347,10 @@ class TreeView(Gtk.TreeView): key = query_config("steam_api")[0] job = Servers.query_api params = Servers.params + serv = [] with ThreadPoolExecutor() as executor: - futures = [executor.submit(job, key, param) for param in params] + futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] wait(futures) - serv = [] for future in futures: res = future.result() if res.status != 200 or not res.parsed: @@ -2208,7 +2360,13 @@ class TreeView(Gtk.TreeView): return j = res.json serv += j["response"]["servers"] - parsed = Servers.parse_json(serv) + + res = Servers.query_api(key, APPID_DAYZ_EXP, "") + if res.status == 200 and res.parsed is True: + j = res.json + serv += j["response"]["servers"] + + parsed = Servers.parse_json(serv) return parsed def _dump_lan(self, port: int) -> list | None: @@ -2649,27 +2807,15 @@ class TreeView(Gtk.TreeView): ) thread.start() - def _background_connection( - self, dialog: "GenericDialog", record: str - ) -> None: - def load(): - dialog.destroy() - out = proc.stdout.splitlines() - msg = out[-1] - process_shell_return_code(msg, proc.returncode, record) + def dialog_hide(self) -> None: + if hasattr(self, "wait_dialog"): + self.wait_dialog.destroy() - proc = call_out("Connect from table", record) - GLib.idle_add(load) - - def _attempt_connection(self) -> None: - record = self.get_record_string() - msg = "Querying server and aligning mods" - wait_dialog = GenericDialog(msg, Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread( - target=self._background_connection, args=(wait_dialog, record) - ) - thread.start() + def dialog_show(self, msg: str) -> None: + if hasattr(self, "wait_dialog"): + self.wait_dialog.destroy() + self.wait_dialog = GenericDialog(msg, Popup.WAIT) + self.wait_dialog.show_all() def is_row_to_server_context(self, view: RowType) -> bool: """Row activation that jumps into a server table""" @@ -2709,6 +2855,86 @@ class TreeView(Gtk.TreeView): def get_view(self): return self.view + def prepare_connection(self, record: Record) -> subprocess.CompletedProcess | None: + """ + Always called on a thread with a dialog on the transient parent window + """ + prereqs = Servers.get_prereqs(record.ip, record.qport) + if prereqs.appid is None: + msg = "Timed out when querying server, check IP or try again later" + spawn_dialog(msg, Popup.NOTIFY) + return None + + if prereqs.version is not None: + path = query_config("default_steam_path")[0] + result = PeFile.compare_versions(prereqs.version, prereqs.appid, path) + + if result.error is not None: + logger.warning(result.error) + + if result.match == VersionMatch.FAIL: + if isinstance(result.error, VDFLoadError) or isinstance(result.error, PeFileError): + # permissive; file exists, but could not determine version + pass + if isinstance(result.error, AppNotInstalledError): + if prereqs.appid == 1024020: + msg = ( + "This server is running DayZ Experimental, a beta build. " + "You can install DayZ Experimental by searching for it in " + "your Steam library." + ) + spawn_dialog(msg, Popup.NOTIFY) + return None + if isinstance(result.error, AppMovedError): + msg = ( + f"Steam is reporting that {result.build} is installed at a non-existent location. " + f"If you recently installed {result.build} or moved it to a different drive, " + "restart Steam to allow these changes to synchronize, then try again." + ) + spawn_dialog(msg, Popup.NOTIFY) + return None + + if result.match == VersionMatch.LOCAL_OLDER: + msg = ( + f"This server is running a newer build ({result.remote}) of {result.build} than " + f"your local version ({result.local}). You may be unable to connect. Proceed anyway?" + ) + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return None + + if result.match == VersionMatch.LOCAL_NEWER: + msg = ( + f"This server is running an out-of-date build ({result.remote}) of {result.build}. " + "You may be unable to connect. Proceed anyway?" + ) + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return None + + if prereqs.password is True: + msg = ( + "This server is password-protected and you will be " + "prompted when connecting. Do you want to proceed?" + ) + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return None + + """ + When using RowType.CONN_BY_IP, the gameport needs to be interpolated + """ + + record.gameport = prereqs.gameport + addr = record_to_str(record) + proc = call_out( + "try_connect", + addr, + str(prereqs.appid), + str(result.path) + ) + return proc + @signal_emission @update_window_labels def _on_row_activated( @@ -2777,15 +3003,13 @@ class TreeView(Gtk.TreeView): record = self.get_record_dict() if record is None: return - if Servers.is_passworded(record["ip"], int(record["qport"])): - msg = ( - "This server is password-protected and you will be " - "prompted when connecting. Do you want to proceed?" - ) - res = spawn_dialog(msg, Popup.CONFIRM) - if res is True: - return - self._attempt_connection() + thread_new_with_dialog( + self.prepare_connection, + parse_shell_output, + "Querying server", + None, + [record] + ) case _: # any other non-server option from the main menu process_tree_option(output) @@ -3042,7 +3266,7 @@ class LanDialog(Gtk.MessageDialog): class DetailsDialog(GenericDialog): - def __init__(self, server_name: str, ip: str, qport: str): + def __init__(self, server_name: str, ip: str, qport: int): super().__init__(server_name, Popup.DETAILS) dialog_box = self.get_content_area() @@ -3050,7 +3274,7 @@ class DetailsDialog(GenericDialog): self.set_size_request(800, 700) self.ip = ip.split(":")[0] - self.qport = int(qport) + self.qport = qport self.store = Gtk.ListStore(str, str, Pango.Weight) self.view = Gtk.TreeView( @@ -3203,12 +3427,10 @@ class ModDialog(GenericDialog): self.run() self.destroy() - addr = App.treeview.get_record_dict() - if not addr: + record = App.treeview.get_record_dict() + if not record: return - ip = addr["ip"] - qport = addr["qport"] - data = call_out("show_server_modlist", ip, qport) + data = call_out("show_server_modlist", record.ip, str(record.qport)) mod_count = self._parse_modlist_rows(data) self.view.set_model(modlist_store) GLib.idle_add(_load) @@ -3487,6 +3709,9 @@ class Options(Gtk.Box): [LeftLabel("Force update local mods"), self.force_button, eb2], ] + self.dayz_version_label = Gtk.Label(label="-") + self.dayz_exp_version_label = Gtk.Label(label="-") + self.branch_combo = Gtk.ComboBoxText() self.branch_combo.append_text("Stable") self.branch_combo.append_text("Testing") @@ -3499,7 +3724,11 @@ class Options(Gtk.Box): ) eb = InfoEventBox(msg) - version_rows = [[LeftLabel("Branch"), self.branch_combo, eb]] + version_rows = [ + [LeftLabel("DayZ"), self.dayz_version_label], + [LeftLabel("DayZ Experimental"), self.dayz_exp_version_label], + [LeftLabel("DZGUI branch"), self.branch_combo, eb] + ] api_grid = self._make_grid(api_rows) prefs_grid = self._make_grid(pref_rows) @@ -3743,6 +3972,7 @@ class Options(Gtk.Box): name = config_vals[3] client = config_vals[5] fullscreen = config_vals[6] + default_steam_path = config_vals[7] try: steam = query_config("steam_api")[0] @@ -3785,6 +4015,21 @@ class Options(Gtk.Box): if field[0] == "": field[1].get_children()[1].set_sensitive(False) + try: + pe_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ) + dayz_version = PeFile.get_dayz_version_str(pe_file_path) + except Exception: + dayz_version = "-" + + try: + exp_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ_EXP) + dayz_exp_version = PeFile.get_dayz_version_str(exp_file_path) + except Exception: + dayz_exp_version = "-" + + self.dayz_version_label.set_text(dayz_version) + self.dayz_exp_version_label.set_text(dayz_exp_version) + if branch == "testing": self.branch_combo.set_active(1) else: From b92e337016d00dd8313dd3ac333581f378d06ce9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:57:36 +0900 Subject: [PATCH 116/221] chore: bump version --- dzgui.sh | 9 +++++---- helpers/funcs | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/dzgui.sh b/dzgui.sh index 46cec62..a0d14d1 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -o pipefail -version=6.0.0.beta-5 +version=6.0.0.beta-6 #CONSTANTS aid=221100 @@ -585,11 +585,12 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["funcs"]="01d45663b7517eae866010df0eba746a" + ["funcs"]="f1db0e8b1068defdf834e9c9510bf315" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" - ["servers.py"]="ea5648df7121bb9dfeead9874bfcafcf" - ["ui.py"]="38b589e4b4fd9a9d3e049e7dcbdc8593" + ["servers.py"]="3610debc3f2931d2aa7c002ae912db88" + ["ui.py"]="567b39cefb08f66f63f77cae5fa0c94f" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" + ["pefile.py"]="cc23ff2725fedb1c64908f77477360b6" ) local author="aclist" local repo="dztui" diff --git a/helpers/funcs b/helpers/funcs index 2a41ece..8033d09 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -o pipefail -version="6.0.0-beta.5" +version="6.0.0-beta.6" #CONSTANTS aid=221100 From 0d52c83ed93f45bad2ed78de77f9a16d3fb8dbf9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:58:58 +0900 Subject: [PATCH 117/221] chore: refactor pefile methods --- helpers/pefile.py | 81 +++++++++++++----------------- helpers/ui.py | 122 ++++++++++++++++++++++++++++------------------ 2 files changed, 110 insertions(+), 93 deletions(-) diff --git a/helpers/pefile.py b/helpers/pefile.py index 0b026d0..507a355 100644 --- a/helpers/pefile.py +++ b/helpers/pefile.py @@ -24,7 +24,6 @@ class VersionMatch(Enum): LOCAL_OLDER = 1 LOCAL_NEWER = 2 SAME_VERSION = 3 - FAIL = 4 class u8: @@ -288,17 +287,24 @@ def seek_to_pe_stub(data: BinaryIO) -> None: raise PeFileError("missing PE header data") -def get_dayz_version(file: Path) -> DayZVersion: - version = get_version(file) +def get_dayz_version(file: Path) -> DayZVersion | Exception: + try: + version = get_version(file) + except Exception as e: + return e patch = str(version.build) + str(version.revision) dz_vers = DayZVersion(version.major, version.minor, int(patch)) return dz_vers -def get_dayz_version_str(file: Path) -> str: - v = get_dayz_version(file) - concat = ".".join(str(el) for el in [v.major, v.minor, v.patch]) - return concat +def dayz_version_to_str(v: DayZVersion) -> str: + return ".".join(str(el) for el in [v.major, v.minor, v.patch]) + + +def dayz_version_from_str(v: str) -> DayZVersion: + vers = v.split(".") + assert len(vers) == 3 + return DayZVersion(*[int(el) for el in vers]) def get_version(file): @@ -389,14 +395,6 @@ def get_version(file): return version -def is_older_version(local: str, remote: str) -> bool: - return Version(local) < Version(remote) - - -def is_newer_version(local: str, remote: str) -> bool: - return Version(local) > Version(remote) - - def get_pefile_path(path: str, appid: int) -> Path: binary = "DayZ_x64.exe" identifier = {221100: "DayZ", 1024020: "DayZ Exp"} @@ -431,38 +429,29 @@ def get_pefile_path(path: str, appid: int) -> Path: return pe_path -def compare_versions(remote: str, appid: int, path: str): - if appid == 221100: - build = "DayZ" - else: - build = "DayZ Experimental" +def compare_versions(local: DayZVersion, remote: DayZVersion): + """ + packaging.version module is not available OOTB on some distributions + """ + if dayz_version_to_str(local) == dayz_version_to_str(remote): + return VersionMatch.SAME_VERSION - local = None - pe_filepath = None - error = None - - try: - pe_filepath = get_pefile_path(path, appid) - except Exception as e: - return Result( - local, remote, build, pe_filepath, VersionMatch.FAIL, e - ) - - try: - local = get_dayz_version_str(pe_filepath) - except PeFileError: - return Result( - local, remote, build, pe_filepath, VersionMatch.FAIL, error - ) - - if is_older_version(local, remote): - res = VersionMatch.LOCAL_OLDER - elif is_newer_version(local, remote): - res = VersionMatch.LOCAL_NEWER - else: - res = VersionMatch.SAME_VERSION - - return Result(local, remote, build, pe_filepath, res, error) + if local.major < remote.major: + return VersionMatch.LOCAL_OLDER + if local.major > remote.major: + return VersionMatch.LOCAL_NEWER + if local.major == remote.major: + if local.minor < remote.minor: + return VersionMatch.LOCAL_OLDER + if local.minor > remote.minor: + return VersionMatch.LOCAL_NEWER + if local.minor == remote.minor: + if local.patch < remote.patch: + return VersionMatch.LOCAL_OLDER + if local.patch > remote.patch: + return VersionMatch.LOCAL_NEWER + if local.patch == remote.patch: + return VersionMatch.SAME_VERSION def vdf_to_json(stream): diff --git a/helpers/ui.py b/helpers/ui.py index 0c3f33b..413b159 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -24,7 +24,7 @@ import servers as Servers # noqa E402 import pefile as PeFile # noqa E402 from pefile import VDFLoadError, AppNotInstalledError, AppMovedError, PeFileError -from pefile import VersionMatch +from pefile import VersionMatch, DayZVersion locale.setlocale(locale.LC_ALL, "") @@ -2861,56 +2861,83 @@ class TreeView(Gtk.TreeView): """ prereqs = Servers.get_prereqs(record.ip, record.qport) if prereqs.appid is None: + logger.warning(f"Query to '{record.ip}:{record.qport}' timed out") msg = "Timed out when querying server, check IP or try again later" spawn_dialog(msg, Popup.NOTIFY) return None - if prereqs.version is not None: - path = query_config("default_steam_path")[0] - result = PeFile.compare_versions(prereqs.version, prereqs.appid, path) + build = "DayZ" if prereqs.appid == APPID_DAYZ else "DayZ Experimental" + steam_path = query_config("default_steam_path")[0] - if result.error is not None: - logger.warning(result.error) + if len(steam_path) < 1: + logger.critical("Config file has no value set for 'default_steam_path'") + msg = f"Local Steam installation is not set, possibly malformed config file." + spawn_dialog(msg, Popup.NOTIFY) + return None - if result.match == VersionMatch.FAIL: - if isinstance(result.error, VDFLoadError) or isinstance(result.error, PeFileError): - # permissive; file exists, but could not determine version - pass - if isinstance(result.error, AppNotInstalledError): - if prereqs.appid == 1024020: - msg = ( - "This server is running DayZ Experimental, a beta build. " - "You can install DayZ Experimental by searching for it in " - "your Steam library." - ) - spawn_dialog(msg, Popup.NOTIFY) - return None - if isinstance(result.error, AppMovedError): + try: + pefile_path = PeFile.get_pefile_path(steam_path, prereqs.appid) + except AppNotInstalledError: + logger.critical(f"'{prereqs.appid}' not found in user's libraryfolders") + msg = ( + f"This server is running {build}. " + f"You can install {build} by searching for it in " + "your Steam library." + ) + spawn_dialog(msg, Popup.NOTIFY) + return None + except AppMovedError: + logger.critical(f"Library folder synch error for '{prereqs.appid}'") + msg = ( + f"Steam is reporting that {build} is installed at a non-existent location. " + f"If you recently installed {build} or moved it to a different drive, " + "restart Steam to allow these changes to synchronize, then try again." + ) + spawn_dialog(msg, Popup.NOTIFY) + return None + except (VDFLoadError, PeFileError, Exception) as e: + logger.critical(e) + msg = "Steam settings or DayZ installation may be corrupted. Try restarting Steam." + spawn_dialog(msg, Popup.NOTIFY) + return None + + try: + local_vers = PeFile.get_dayz_version(pefile_path) + except (PeFileError, Exception) as e: + """ + Currently permissive; file exists, but was unparseable. + """ + logger.warning(f"Failed to parse PE file: {e}") + local_vers = None + + try: + remote_vers = PeFile.dayz_version_from_str(prereqs.version) + except Exception: + remote_vers = None + + if (local_vers is not None and + remote_vers is not None): + match = PeFile.compare_versions(local_vers, remote_vers) + + match match: + case VersionMatch.LOCAL_OLDER: msg = ( - f"Steam is reporting that {result.build} is installed at a non-existent location. " - f"If you recently installed {result.build} or moved it to a different drive, " - "restart Steam to allow these changes to synchronize, then try again." + f"This server is running a newer build ({prereqs.version}) of {build} than " + f"your local version. You may be unable to connect. Proceed anyway?" ) - spawn_dialog(msg, Popup.NOTIFY) - return None - - if result.match == VersionMatch.LOCAL_OLDER: - msg = ( - f"This server is running a newer build ({result.remote}) of {result.build} than " - f"your local version ({result.local}). You may be unable to connect. Proceed anyway?" - ) - res = spawn_dialog(msg, Popup.CONFIRM) - if res is True: - return None - - if result.match == VersionMatch.LOCAL_NEWER: - msg = ( - f"This server is running an out-of-date build ({result.remote}) of {result.build}. " - "You may be unable to connect. Proceed anyway?" - ) - res = spawn_dialog(msg, Popup.CONFIRM) - if res is True: - return None + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return None + case VersionMatch.LOCAL_NEWER: + msg = ( + f"This server is running an out-of-date build ({prereqs.version}) of {build}. " + "You may be unable to connect. Proceed anyway?" + ) + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return None + case VersionMatch.SAME_VERSION: + pass if prereqs.password is True: msg = ( @@ -2924,14 +2951,13 @@ class TreeView(Gtk.TreeView): """ When using RowType.CONN_BY_IP, the gameport needs to be interpolated """ - record.gameport = prereqs.gameport addr = record_to_str(record) proc = call_out( "try_connect", addr, str(prereqs.appid), - str(result.path) + str(pefile_path) ) return proc @@ -4017,13 +4043,15 @@ class Options(Gtk.Box): try: pe_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ) - dayz_version = PeFile.get_dayz_version_str(pe_file_path) + vers = PeFile.get_dayz_version(pe_file_path) + dayz_version = PeFile.dayz_version_to_str(vers) except Exception: dayz_version = "-" try: exp_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ_EXP) - dayz_exp_version = PeFile.get_dayz_version_str(exp_file_path) + vers = PeFile.get_dayz_version(exp_file_path) + dayz_exp_version = PeFile.dayz_version_to_str(vers) except Exception: dayz_exp_version = "-" From 5482f296a54e3bcef8ca8185e64d788adccb7d4b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:01:57 +0900 Subject: [PATCH 118/221] chore: formatting --- helpers/pefile.py | 47 ++++++++++++++++------------- helpers/ui.py | 77 +++++++++++++++++++++++++++-------------------- 2 files changed, 70 insertions(+), 54 deletions(-) diff --git a/helpers/pefile.py b/helpers/pefile.py index 507a355..b76181d 100644 --- a/helpers/pefile.py +++ b/helpers/pefile.py @@ -200,6 +200,7 @@ class RESOURCE_DIRECTORY_ENTRY(PackedData): (the name consists of 16 bits length and trailing wide characters, in Unicode, not 0-terminated). """ + name_or_id: u32 data_or_subdir: u32 @@ -246,30 +247,34 @@ class Result: class PeFileError(Exception): """Expected contents missing from headers or resource nodes""" + pass class AppNotInstalledError(Exception): """App not present in user's libraryfolders""" + pass class AppMovedError(Exception): """VDF points to a nonexistent location on disk""" + pass class VDFLoadError(Exception): """Malformed VDF or JSON conversion""" + pass def parse_version_number(data: BinaryIO): # https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo - minor = struct.unpack("> 16 & 0xffff - major = struct.unpack(" > 0 & 0xffff - build = struct.unpack(" > 0 & 0xffff - revision = struct.unpack(" > 16 & 0xffff + minor = struct.unpack(" > 16 & 0xFFFF + major = struct.unpack(" > 0 & 0xFFFF + build = struct.unpack(" > 0 & 0xFFFF + revision = struct.unpack(" > 16 & 0xFFFF return FileVersion(major, minor, build, revision) @@ -358,9 +363,9 @@ def get_version(file): seek_to_hex(hex(offset + shift), f) table = RESOURCE_DIRECTORY_TABLE.unpack(f) total = ( - table.number_of_name_entries + - table.number_of_id_entries - ) + table.number_of_name_entries + + table.number_of_id_entries + ) for entry in range(total): entry = RESOURCE_DIRECTORY_ENTRY.unpack(f) break @@ -456,33 +461,33 @@ def compare_versions(local: DayZVersion, remote: DayZVersion): def vdf_to_json(stream): def _istr(indent, string): - return (indent * ' ') + string + return (indent * " ") + string - jbuf = '{\n' + jbuf = "{\n" lex = shlex(stream) indent = 1 while True: tok = lex.get_token() if not tok: - return jbuf + '}\n' - if tok == '}': + return jbuf + "}\n" + if tok == "}": indent -= 1 - jbuf += _istr(indent, '}') + jbuf += _istr(indent, "}") ntok = lex.get_token() lex.push_token(ntok) - if ntok and ntok != '}': - jbuf += ',' - jbuf += '\n' + if ntok and ntok != "}": + jbuf += "," + jbuf += "\n" else: ntok = lex.get_token() - if ntok == '{': - jbuf += _istr(indent, tok + ': {\n') + if ntok == "{": + jbuf += _istr(indent, tok + ": {\n") indent += 1 else: - jbuf += _istr(indent, tok + ': ' + ntok) + jbuf += _istr(indent, tok + ": " + ntok) ntok = lex.get_token() lex.push_token(ntok) - if ntok != '}': - jbuf += ',' - jbuf += '\n' + if ntok != "}": + jbuf += "," + jbuf += "\n" diff --git a/helpers/ui.py b/helpers/ui.py index 413b159..c16296c 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -23,12 +23,18 @@ from typing import Literal, Self, Any import servers as Servers # noqa E402 import pefile as PeFile # noqa E402 -from pefile import VDFLoadError, AppNotInstalledError, AppMovedError, PeFileError +from pefile import ( + VDFLoadError, + AppNotInstalledError, + AppMovedError, + PeFileError, +) from pefile import VersionMatch, DayZVersion locale.setlocale(locale.LC_ALL, "") import gi # noqa E402 + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 @@ -725,7 +731,7 @@ def format_metadata(row_sel: str) -> str: "fav_label": config_vals[4], "preferred_client": config_vals[5], "fullscreen": config_vals[6], - "default_steam_path": config_vals[7] + "default_steam_path": config_vals[7], } if row is None: return "" @@ -936,6 +942,7 @@ def call_on_thread( """ Exclusively used for threaded subprocesses """ + def _background(subproc: str, args: str, dialog): def _load() -> None: wait_dialog.destroy() @@ -1011,7 +1018,7 @@ def process_tree_option(choice: RowType) -> None: parse_shell_output, "Querying server", command, - [record] + [record], ) return @@ -1045,9 +1052,8 @@ def thread_new_with_dialog( callback: Callable | None, msg: str, row: RowType | None, - args: list + args: list, ) -> None: - """ Pop a GenericDialog transient to App.treeview and call a function on a thread, with optional callback. @@ -1128,11 +1134,7 @@ def connect_by_ip(enum: RowType, response: str) -> None: return proc thread_new_with_dialog( - _prep, - parse_shell_output, - "Querying IP", - enum, - [response] + _prep, parse_shell_output, "Querying IP", enum, [response] ) return @@ -1144,11 +1146,7 @@ def connect_by_id(enum: RowType, response: str, key: str) -> None: return proc thread_new_with_dialog( - _prep, - parse_shell_output, - "Querying API", - enum, - [key, response] + _prep, parse_shell_output, "Querying API", enum, [key, response] ) return @@ -1161,7 +1159,9 @@ def process_user_input(enum: RowType) -> None: if enum == RowType.CONN_BY_ID: key = query_config("api_key")[0] if len(key) == 0: - spawn_dialog("No Battlemetrics API key is set; see Options", Popup.NOTIFY) + spawn_dialog( + "No Battlemetrics API key is set; see Options", Popup.NOTIFY + ) return user_entry = EntryDialog(prompt, Popup.ENTRY, link_label) @@ -2349,7 +2349,10 @@ class TreeView(Gtk.TreeView): params = Servers.params serv = [] with ThreadPoolExecutor() as executor: - futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] + futures = [ + executor.submit(job, key, APPID_DAYZ, param) + for param in params + ] wait(futures) for future in futures: res = future.result() @@ -2855,7 +2858,9 @@ class TreeView(Gtk.TreeView): def get_view(self): return self.view - def prepare_connection(self, record: Record) -> subprocess.CompletedProcess | None: + def prepare_connection( + self, record: Record + ) -> subprocess.CompletedProcess | None: """ Always called on a thread with a dialog on the transient parent window """ @@ -2870,7 +2875,9 @@ class TreeView(Gtk.TreeView): steam_path = query_config("default_steam_path")[0] if len(steam_path) < 1: - logger.critical("Config file has no value set for 'default_steam_path'") + logger.critical( + "Config file has no value set for 'default_steam_path'" + ) msg = f"Local Steam installation is not set, possibly malformed config file." spawn_dialog(msg, Popup.NOTIFY) return None @@ -2878,7 +2885,9 @@ class TreeView(Gtk.TreeView): try: pefile_path = PeFile.get_pefile_path(steam_path, prereqs.appid) except AppNotInstalledError: - logger.critical(f"'{prereqs.appid}' not found in user's libraryfolders") + logger.critical( + f"'{prereqs.appid}' not found in user's libraryfolders" + ) msg = ( f"This server is running {build}. " f"You can install {build} by searching for it in " @@ -2887,7 +2896,9 @@ class TreeView(Gtk.TreeView): spawn_dialog(msg, Popup.NOTIFY) return None except AppMovedError: - logger.critical(f"Library folder synch error for '{prereqs.appid}'") + logger.critical( + f"Library folder synch error for '{prereqs.appid}'" + ) msg = ( f"Steam is reporting that {build} is installed at a non-existent location. " f"If you recently installed {build} or moved it to a different drive, " @@ -2915,8 +2926,7 @@ class TreeView(Gtk.TreeView): except Exception: remote_vers = None - if (local_vers is not None and - remote_vers is not None): + if local_vers is not None and remote_vers is not None: match = PeFile.compare_versions(local_vers, remote_vers) match match: @@ -2954,10 +2964,7 @@ class TreeView(Gtk.TreeView): record.gameport = prereqs.gameport addr = record_to_str(record) proc = call_out( - "try_connect", - addr, - str(prereqs.appid), - str(pefile_path) + "try_connect", addr, str(prereqs.appid), str(pefile_path) ) return proc @@ -3034,7 +3041,7 @@ class TreeView(Gtk.TreeView): parse_shell_output, "Querying server", None, - [record] + [record], ) case _: # any other non-server option from the main menu process_tree_option(output) @@ -3388,7 +3395,7 @@ class DetailsDialog(GenericDialog): reg = r"\s(www\.*?)" text = re.sub(reg, " http://" + r"\1", text) reg2 = r"(http.*?)([ ,\r\n]|$)" - text = re.sub(reg2, comp(r"\1")+r"\2", text) + text = re.sub(reg2, comp(r"\1") + r"\2", text) self.description.set_markup(text) self.success = response.success @@ -3753,7 +3760,7 @@ class Options(Gtk.Box): version_rows = [ [LeftLabel("DayZ"), self.dayz_version_label], [LeftLabel("DayZ Experimental"), self.dayz_exp_version_label], - [LeftLabel("DZGUI branch"), self.branch_combo, eb] + [LeftLabel("DZGUI branch"), self.branch_combo, eb], ] api_grid = self._make_grid(api_rows) @@ -4042,14 +4049,18 @@ class Options(Gtk.Box): field[1].get_children()[1].set_sensitive(False) try: - pe_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ) + pe_file_path = PeFile.get_pefile_path( + default_steam_path, APPID_DAYZ + ) vers = PeFile.get_dayz_version(pe_file_path) dayz_version = PeFile.dayz_version_to_str(vers) except Exception: dayz_version = "-" try: - exp_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ_EXP) + exp_file_path = PeFile.get_pefile_path( + default_steam_path, APPID_DAYZ_EXP + ) vers = PeFile.get_dayz_version(exp_file_path) dayz_exp_version = PeFile.dayz_version_to_str(vers) except Exception: @@ -4568,7 +4579,7 @@ class ModSelectionPanel(Gtk.Box): { "label": "Highlight stale", "tooltip": "Shows locally-installed mods which are not\n" - "used by any server in your Saved Servers", + "used by any server in your Saved Servers", }, ] From 364f0b3482d467a47077f44091e1455cbe342cf6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:06:39 +0900 Subject: [PATCH 119/221] chore: update dataclass params --- helpers/servers.py | 9 ++++++--- helpers/ui.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/helpers/servers.py b/helpers/servers.py index 565ad0a..f86dae7 100644 --- a/helpers/servers.py +++ b/helpers/servers.py @@ -201,7 +201,7 @@ def query_direct(ip: str, qport: int, TIMEOUT=3.0) -> dict | None: return None -@dataclass +@dataclass(slots=True, frozen=True) class Res: status: int parsed: bool @@ -215,14 +215,14 @@ class Ping: ping: int -@dataclass +@dataclass(slots=True, frozen=True) class Details: data: Union[list, None] description: str success: bool -@dataclass +@dataclass(slots=True, frozen=True) class Prereqs: password: bool gameport: int @@ -231,6 +231,9 @@ class Prereqs: @dataclass(slots=True) +""" +The gameport field is manipulated by the RowType.CONN_BY_IP method +""" class Record: ip: str gameport: int diff --git a/helpers/ui.py b/helpers/ui.py index c16296c..3a1a9de 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -99,7 +99,7 @@ If this issue persists, your API key may be defunct. """ -@dataclass +@dataclass(slots=True) class Record: ip: str gameport: int From 4ddb33cabb2eb0c6dd52204acc68dfbd84f0274f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:08:06 +0900 Subject: [PATCH 120/221] chore: update checksums --- dzgui.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dzgui.sh b/dzgui.sh index a0d14d1..3bf4e0c 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -587,10 +587,10 @@ fetch_helpers_by_sum(){ sums=( ["funcs"]="f1db0e8b1068defdf834e9c9510bf315" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" - ["servers.py"]="3610debc3f2931d2aa7c002ae912db88" - ["ui.py"]="567b39cefb08f66f63f77cae5fa0c94f" + ["servers.py"]="f830383f1da7bc424e4e9f882395e357" + ["ui.py"]="3258d5c85ef22517277c213ee2fc9b1d" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" - ["pefile.py"]="cc23ff2725fedb1c64908f77477360b6" + ["pefile.py"]="894450c3d3480f3292ce1ff765c56719" ) local author="aclist" local repo="dztui" From 7b6722288588992f8af5fe4a71692c46cd874536 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:09:28 +0900 Subject: [PATCH 121/221] fix: move docstring --- helpers/servers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/helpers/servers.py b/helpers/servers.py index f86dae7..ef26b32 100644 --- a/helpers/servers.py +++ b/helpers/servers.py @@ -231,10 +231,10 @@ class Prereqs: @dataclass(slots=True) -""" -The gameport field is manipulated by the RowType.CONN_BY_IP method -""" class Record: + """ + The gameport field is manipulated by the RowType.CONN_BY_IP method + """ ip: str gameport: int qport: int From 2edb81247bf340f1fc34126ec40faa0e6bb26ec0 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:09:59 +0900 Subject: [PATCH 122/221] chore: update checksums --- dzgui.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui.sh b/dzgui.sh index 3bf4e0c..5d079a1 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -587,7 +587,7 @@ fetch_helpers_by_sum(){ sums=( ["funcs"]="f1db0e8b1068defdf834e9c9510bf315" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" - ["servers.py"]="f830383f1da7bc424e4e9f882395e357" + ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" ["ui.py"]="3258d5c85ef22517277c213ee2fc9b1d" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["pefile.py"]="894450c3d3480f3292ce1ff765c56719" @@ -1030,7 +1030,7 @@ initial_setup(){ watcher_deps check_architecture test_connection - fetch_helpers > >(pdialog "Checking helper files") + fetch_helpers > >(pdialog "Checking helper files") varcheck source "$config_file" lock From 60ce184d8f4e6a3c41bc4b8115615e8a5339dafa Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:10:16 +0900 Subject: [PATCH 123/221] fix: whitespace --- dzgui.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui.sh b/dzgui.sh index 5d079a1..97ae8d7 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1030,7 +1030,7 @@ initial_setup(){ watcher_deps check_architecture test_connection - fetch_helpers > >(pdialog "Checking helper files") + fetch_helpers > >(pdialog "Checking helper files") varcheck source "$config_file" lock From b500806815646281c1035d9b7848920c719e4954 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:45:58 +0900 Subject: [PATCH 124/221] chore: drop dependency --- helpers/pefile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/helpers/pefile.py b/helpers/pefile.py index b76181d..ab63490 100644 --- a/helpers/pefile.py +++ b/helpers/pefile.py @@ -4,7 +4,6 @@ import typing # noqa from dataclasses import dataclass from enum import Enum -from packaging.version import Version from pathlib import Path from shlex import shlex from typing import BinaryIO, Union From 81c2d19a0963692c8b13b9e2ee9119f759d63c99 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:46:32 +0900 Subject: [PATCH 125/221] chore: update checksums --- dzgui.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui.sh b/dzgui.sh index 97ae8d7..cb41d17 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -590,7 +590,7 @@ fetch_helpers_by_sum(){ ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" ["ui.py"]="3258d5c85ef22517277c213ee2fc9b1d" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" - ["pefile.py"]="894450c3d3480f3292ce1ff765c56719" + ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) local author="aclist" local repo="dztui" From 47f76226061ac10664df922f758b6b69604b5c43 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:33:59 +0900 Subject: [PATCH 126/221] fix: return codes in manual mode --- helpers/funcs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers/funcs b/helpers/funcs index 8033d09..bfde35f 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1084,7 +1084,7 @@ try_connect(){ local gameport=$(<<< $record awk -F: '{print $2}') local qport=$(<<< $record awk -F: '{print $3}') - echo "$binary" > $_cache_binary + [[ $appid -eq $exp ]] && echo "$binary" > $_cache_binary local remote_mods remote_mods=$(a2s $ip $qport rules) @@ -1110,7 +1110,7 @@ try_connect(){ return 1 fi case $auto_install in - "") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "$appid";; + "") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "" "$appid";; 1|2) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" "$appid" ;; esac else From ad64ba75c9ba5acf784cafbeb5d71011de9ad82e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:34:12 +0900 Subject: [PATCH 127/221] chore: reword failure message --- helpers/ui.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/helpers/ui.py b/helpers/ui.py index 3a1a9de..ba6f4ea 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -2889,9 +2889,10 @@ class TreeView(Gtk.TreeView): f"'{prereqs.appid}' not found in user's libraryfolders" ) msg = ( - f"This server is running {build}. " - f"You can install {build} by searching for it in " - "your Steam library." + f"This server is running {build}. You can install " + f"{build} by searching for it in your Steam library. " + f"If you recently installed {build} or moved it to a different drive, " + "restart Steam to allow these changes to synchronize, then try again." ) spawn_dialog(msg, Popup.NOTIFY) return None From 6425f3d8e3d7d131d19130aa276e9c0a3baae1d6 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:35:12 +0900 Subject: [PATCH 128/221] chore: update checksums --- dzgui.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui.sh b/dzgui.sh index cb41d17..18e47f7 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -585,10 +585,10 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["funcs"]="f1db0e8b1068defdf834e9c9510bf315" + ["funcs"]="a286cc402bfccd39493fe32c53148a95" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" - ["ui.py"]="3258d5c85ef22517277c213ee2fc9b1d" + ["ui.py"]="cd9f4b3bc9b1922bb10cbc0c579cf2c0" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) From 9d8bbec03aefe90e3488cde50f425d19538af918 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 12 Sep 2025 18:03:38 +0900 Subject: [PATCH 129/221] chore: update changelog --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f115d..f7a4ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [6.0.0-beta.5] 2025-09-12 +## Added +- Support DayZ Experimental +- Show additional client information in Options menu +- Warn user of client version mismatches +- Support clickable hyperlinks + +## Fixed +- Mods rarely not appearing in local mod list if download completed too quickly +- Statusbar not updating when clicking a row after spamming keyboard input +- Extraneous logs being generated when subscribing to mods +- Narrow width of columns in modlist dialogs occluding text +- Newline terminators in history file +- Floating point number calculation +- Window resizing too small if no prior resolution was set + ## [6.0.0-beta.5] 2025-08-20 ## Fixed - Servers returning malformed A2S_INFO blocking server browser from loading From 304300597760d48736b31e6dbb1ca9699582420f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 19:58:49 +0900 Subject: [PATCH 130/221] chore: drop unused import --- helpers/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index ba6f4ea..55b3d2e 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -29,7 +29,7 @@ from pefile import ( AppMovedError, PeFileError, ) -from pefile import VersionMatch, DayZVersion +from pefile import VersionMatch locale.setlocale(locale.LC_ALL, "") From 054486dfefe2853b3ae17836e8462d4c75c186d1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 19:59:28 +0900 Subject: [PATCH 131/221] chore: rename var --- helpers/ui.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/helpers/ui.py b/helpers/ui.py index 55b3d2e..1b63dfe 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -3549,12 +3549,12 @@ class EntryDialog(GenericDialog): self.dialog.set_default_response(Gtk.ResponseType.OK) self.dialog.set_size_request(500, 0) - self.userEntry = Gtk.Entry() - set_surrounding_margins(self.userEntry, 20) - self.userEntry.set_margin_top(0) - self.userEntry.set_size_request(250, 0) - self.userEntry.set_activates_default(True) - self.dialogBox.pack_start(self.userEntry, False, False, 0) + self.user_entry = Gtk.Entry() + set_surrounding_margins(self.user_entry, 20) + self.user_entry.set_margin_top(0) + self.user_entry.set_size_request(250, 0) + self.user_entry.set_activates_default(True) + self.dialogBox.pack_start(self.user_entry, False, False, 0) if link: button = Gtk.Button(label=link) @@ -3565,8 +3565,8 @@ class EntryDialog(GenericDialog): self.ok = self.dialog.action_area.get_children()[1] self.ok.set_sensitive(False) - self.userEntry.connect("insert-text", self._on_text_typed) - self.userEntry.get_property("buffer").connect( + self.user_entry.connect("insert-text", self._on_text_typed) + self.user_entry.get_property("buffer").connect( "deleted-text", self._on_text_deleted ) @@ -3598,7 +3598,7 @@ class EntryDialog(GenericDialog): self.dialog.show_all() response = self.dialog.run() - text = self.userEntry.get_text() + text = self.user_entry.get_text() self.dialog.destroy() if (response == Gtk.ResponseType.OK) and (text != ""): return text From a03971ce7021983611b2db623fdb3d940cf300b9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 19:59:51 +0900 Subject: [PATCH 132/221] feat: add get_entry() to EntryDialog --- helpers/ui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/helpers/ui.py b/helpers/ui.py index 1b63dfe..3f9879b 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -3594,6 +3594,9 @@ class EntryDialog(GenericDialog): label = button.get_label() call_bash_func("Open link", label) + def get_entry(self) -> Gtk.Entry: + return self.user_entry + def get_input(self) -> str | None: self.dialog.show_all() From 8acd359028796e91d24a24437ac67b5288d1ef66 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 20:00:15 +0900 Subject: [PATCH 133/221] chore: update comments --- helpers/ui.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index 3f9879b..dc1f64f 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -3541,7 +3541,9 @@ class EntryDialog(GenericDialog): super().__init__(text, mode) """ - Returns user input as a string or None + Wraps Gtk.Entry in a dialog and provides basic response handling. + Returns user input as a string or None. + The Entry widget itself can be manipulated via the get_entry() method. """ self.dialog = GenericDialog(text, mode) From adc617730b0104979fe4c7349df5e06f6a09d7fd Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 20:00:42 +0900 Subject: [PATCH 134/221] fix: change f-string --- helpers/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index dc1f64f..10ab6b6 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -2878,7 +2878,7 @@ class TreeView(Gtk.TreeView): logger.critical( "Config file has no value set for 'default_steam_path'" ) - msg = f"Local Steam installation is not set, possibly malformed config file." + msg = "Local Steam installation is not set, possibly malformed config file." spawn_dialog(msg, Popup.NOTIFY) return None From 17229c1cba17fa4b67925c2202acca2dbd609737 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 20:01:51 +0900 Subject: [PATCH 135/221] chore: rename get_record_dict() to get_record() --- helpers/ui.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/helpers/ui.py b/helpers/ui.py index 10ab6b6..80fabc7 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -1942,7 +1942,7 @@ class TreeView(Gtk.TreeView): model = self.get_model() it = self.get_current_iter() name = model.get_value(it, 0) - record = self.get_record_dict() + record = self.get_record() DetailsDialog(name, record.ip, record.qport) def show_mods(self) -> None: @@ -2126,7 +2126,7 @@ class TreeView(Gtk.TreeView): self.view == WindowContext.TABLE_API or self.view == WindowContext.TABLE_SERVER ): - record = self.get_record_dict() + record = self.get_record() if not record: grid.statusbar.update_server_meta() return @@ -2276,7 +2276,7 @@ class TreeView(Gtk.TreeView): qport = self.get_value_at_index(8) return f"{addr}:{qport}" - def get_record_dict(self) -> dict | None: + def get_record(self) -> dict | None: select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels @@ -2334,7 +2334,7 @@ class TreeView(Gtk.TreeView): wait_dialog = GenericDialog("Refreshing player count", Popup.WAIT) wait_dialog.show_all() - record = self.get_record_dict() + record = self.get_record() if not record: return data = call_out("get_player_count", record.ip, str(record.qport)) @@ -3034,7 +3034,7 @@ class TreeView(Gtk.TreeView): case WindowContext.TABLE_MODS | WindowContext.TABLE_LOG: self.update_quad_column(cr) case WindowContext.TABLE_SERVER | WindowContext.TABLE_API: - record = self.get_record_dict() + record = self.get_record() if record is None: return thread_new_with_dialog( @@ -3461,7 +3461,7 @@ class ModDialog(GenericDialog): self.run() self.destroy() - record = App.treeview.get_record_dict() + record = App.treeview.get_record() if not record: return data = call_out("show_server_modlist", record.ip, str(record.qport)) From 01c8a5276bc070ef2a6f8cd041d831bfffc69d40 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 20:02:12 +0900 Subject: [PATCH 136/221] chore: abstract default size params --- helpers/ui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index 80fabc7..1874e7f 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -1265,7 +1265,10 @@ class OuterWindow(Gtk.Window): logger.info(f"Restoring window size to {w},{h}") self.set_default_size(w, h) else: - self.set_default_size(1400, 800) + w = 1400 + h = 800 + logger.info(f"Using default window size {w},{h}") + self.set_default_size(w, h) def _on_delete_event( self, window: "OuterWindow", event: Gdk.EventKey From 38203a9530e473b33409b47217ffa22cd6328b48 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 20:03:35 +0900 Subject: [PATCH 137/221] feat: add read_json(), write_json() --- helpers/ui.py | 106 ++++++++++++++++++++++++-------------------------- 1 file changed, 50 insertions(+), 56 deletions(-) diff --git a/helpers/ui.py b/helpers/ui.py index 1874e7f..1c8833b 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -658,32 +658,42 @@ def block_signals(state: bool = True) -> None: ) +def read_json(path: str) -> str: + try: + with open(path, "r") as infile: + try: + data = json.load(infile) + except json.decoder.JSONDecodeError as e: + raise e + except OSError as e: + raise e + return data + + +def write_json(data: str, path: str) -> None: + try: + j = json.dumps(data, indent=2) + except Exception as e: + raise e + + try: + with open(path, "w") as outfile: + outfile.write(j) + except OSError as e: + raise e + + def save_res_and_quit(*args) -> None: if App.window.props.is_maximized: Gtk.main_quit() return rect = App.window.get_size() - def write_json(rect): - data = {"res": {"width": rect.width, "height": rect.height}} - j = json.dumps(data, indent=2) - with open(res_path, "w") as outfile: - outfile.write(j) - logger.info(f"Wrote window size to '{res_path}'") - - if os.path.isfile(res_path): - with open(res_path, "r") as infile: - try: - data = json.load(infile) - data["res"]["width"] = rect.width - data["res"]["height"] = rect.height - with open(res_path, "w") as outfile: - outfile.write(json.dumps(data, indent=2)) - except json.decoder.JSONDecodeError: - logger.critical(f"JSON decode error in '{res_path}'") - write_json(rect) - else: - write_json(rect) + data = {"res": {"width": rect.width, "height": rect.height}} + try: + write_json(data, res_path) + except Exception as e: + logger.critical(e) Gtk.main_quit() @@ -1249,15 +1259,11 @@ class OuterWindow(Gtk.Window): self.fullscreen() try: - with open(res_path, "r") as infile: - try: - data = json.load(infile) - valid_json = True - except json.decoder.JSONDecodeError: - logger.critical(f"JSON decode error in '{res_path}'") - valid_json = False - except OSError: + data = read_json(res_path) + valid_json = True + except Exception as e: valid_json = False + logger.critical(e) if valid_json: res = data["res"] @@ -2596,39 +2602,27 @@ class TreeView(Gtk.TreeView): def _on_col_width_changed( self, col: Gtk.TreeViewColumn, width: GObject.ParamSpecInt ) -> None: - def write_json(title, size): - data = {"cols": {title: size}} - j = json.dumps(data, indent=2) - with open(geometry_path, "w") as outfile: - outfile.write(j) - logger.info(f"Wrote initial column widths to '{geometry_path}'") - title = col.get_title() size = col.get_width() - if os.path.isfile(geometry_path): - with open(geometry_path, "r") as infile: - try: - data = json.load(infile) - data["cols"][title] = size - with open(geometry_path, "w") as outfile: - outfile.write(json.dumps(data, indent=2)) - except json.decoder.JSONDecodeError: - logger.critical(f"JSON decode error in '{geometry_path}'") - write_json(title, size) - else: - write_json(title, size) + try: + data = read_json(geometry_path) + data["cols"][title] = size + except Exception as e: + logger.critical(e) + data = {"cols": {title: size}} + + try: + write_json(data, geometry_path) + except Exception as e: + logger.critical(e) def initialize_columns(self) -> None: - if os.path.isfile(geometry_path): - with open(geometry_path, "r") as infile: - try: - data = json.load(infile) - valid_json = True - except json.decoder.JSONDecodeError: - logger.critical(f"JSON decode error in '{geometry_path}'") - valid_json = False - else: + try: + data = read_json(geometry_path) + valid_json = True + except Exception as e: + logger.critical(e) valid_json = False browser_cols = [ From cff7b1c2e9ed874d6b131a249acd916225da9a7c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 20:04:44 +0900 Subject: [PATCH 138/221] feat: add note saving --- helpers/ui.py | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/helpers/ui.py b/helpers/ui.py index 1c8833b..e8c4906 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -51,6 +51,7 @@ APPID_DAYZ_EXP = 1024020 cache: dict[str, int] = {} config_vals: list[str] = [] +notes_cache: dict[str, str] = {} _VERSION: str IS_GAME_MODE: bool @@ -77,6 +78,7 @@ servers_path = f"{cache_path}/{app_name_abbr}.servers" config_path = f"{user_path}/.config/dztui" config_file = f"{config_path}/dztuirc" history_file = f"{state_path}/{app_name_abbr}.history" +notes_file = f"{state_path}/{app_name_abbr}.notes.json" logger = logging.getLogger(__name__) log_file = f"{log_path}/{app_name}_DEBUG.log" @@ -424,6 +426,7 @@ class ContextMenu(EnumWithAttrs): "label": "Copy IP to clipboard", "action": "copy_clipboard", } + ADD_NOTE = {"label": "Add note", "action": "add_note"} SHOW_MODS = {"label": "Show server-side mods", "action": "show_mods"} SHOW_DETAILS = {"label": "Server details", "action": "show_details"} REFRESH_PLAYERS = { @@ -1229,6 +1232,12 @@ class OuterWindow(Gtk.Window): self.grid.right_panel.enable_ping_button(False) self.grid.sel_panel.set_visible(False) + global notes_cache + try: + notes_cache = read_json(notes_file) + except Exception as e: + logger.warning(e) + # convenience to avoid deep calls App.window = self App.grid = self.grid @@ -1821,6 +1830,8 @@ class TreeView(Gtk.TreeView): self.sel_blocked = False self.set_fixed_height_mode(True) + self.set_has_tooltip(True) + self.connect("query-tooltip", self._on_tooltip) self.queue = multiprocessing.Queue() self.current_proc = None @@ -1851,6 +1862,32 @@ class TreeView(Gtk.TreeView): self.connect("key-press-event", self._on_keypress) self.connect("key-release-event", self._on_key_release) + def _on_tooltip( + self, + widget: Gtk.Widget, + x: int, + y: int, + keyboard_mode: bool, + tooltip: Gtk.Tooltip, + ) -> bool: + if self.is_server_context(self.view) is False: + return + coords = widget.convert_widget_to_bin_window_coords(x, y) + path = self.get_path_at_pos(coords.bx, coords.by) + if path is None: + return False + + model = self.get_model() + tree_iter = model.get_iter(path[0]) + ip = model.get_value(tree_iter, 7) + qport = model.get_value(tree_iter, 8) + addr = ip + ":" + str(qport) + if addr not in notes_cache: + return False + tooltip.set_text(notes_cache[addr]) + self.set_tooltip_row(tooltip, path[0]) + return True + def _update_mod_store(self) -> None: (model, pathlist) = self.get_selection().get_selected_rows() for p in reversed(pathlist): @@ -1880,6 +1917,43 @@ class TreeView(Gtk.TreeView): if self.current_proc and self.current_proc.is_alive(): self.current_proc.terminate() + def _delete_note( + self, button: Gtk.Button, user_entry: Gtk.Box, addr: str + ) -> None: + box = button.get_parent() + dialog = box.get_parent() + try: + write_json(notes_cache, notes_file) + del notes_cache[addr] + except Exception as e: + logger.critical(e) + dialog.destroy() + + def add_note(self) -> None: + user_entry = EntryDialog( + "Add a short note/reminder. Limit: 30 chars", Popup.ENTRY, "" + ) + entry = user_entry.get_entry() + entry.set_max_length(30) + + addr = self.get_record_string() + if addr in notes_cache: + entry.set_text(notes_cache[addr]) + button = Gtk.Button(label="Delete note") + button.connect("clicked", self._delete_note, user_entry, addr) + button.set_margin_start(50) + button.set_margin_end(50) + user_entry.dialogBox.pack_end(button, False, False, 0) + + response = user_entry.get_input() + if response is None: + return + notes_cache[addr] = response + try: + write_json(notes_cache, notes_file) + except Exception as e: + logger.critical(e) + def copy_name(self) -> None: self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) record = self.get_name() @@ -2037,6 +2111,7 @@ class TreeView(Gtk.TreeView): ContextMenu.ADD_SERVER, ContextMenu.COPY_NAME, ContextMenu.COPY_CLIPBOARD, + ContextMenu.ADD_NOTE, ContextMenu.SHOW_MODS, ContextMenu.SHOW_DETAILS, ContextMenu.REFRESH_PLAYERS, @@ -2044,6 +2119,7 @@ class TreeView(Gtk.TreeView): RowType.SCAN_LAN: [ ContextMenu.COPY_NAME, ContextMenu.COPY_CLIPBOARD, + ContextMenu.ADD_NOTE, ContextMenu.SHOW_MODS, ContextMenu.SHOW_DETAILS, ContextMenu.REFRESH_PLAYERS, @@ -2052,6 +2128,7 @@ class TreeView(Gtk.TreeView): ContextMenu.REMOVE_SERVER, ContextMenu.COPY_NAME, ContextMenu.COPY_CLIPBOARD, + ContextMenu.ADD_NOTE, ContextMenu.SHOW_MODS, ContextMenu.SHOW_DETAILS, ContextMenu.REFRESH_PLAYERS, @@ -2060,6 +2137,7 @@ class TreeView(Gtk.TreeView): ContextMenu.ADD_SERVER, ContextMenu.REMOVE_HISTORY, ContextMenu.COPY_NAME, + ContextMenu.ADD_NOTE, ContextMenu.COPY_CLIPBOARD, ContextMenu.SHOW_MODS, ContextMenu.SHOW_DETAILS, @@ -2086,6 +2164,10 @@ class TreeView(Gtk.TreeView): if row == ContextMenu.SHOW_MODS: if not self.has_mods(): item.set_sensitive(False) + if row == ContextMenu.ADD_NOTE: + if self.get_record_string() in notes_cache: + item.set_label("Edit note") + self.menu.show_all() if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: From 155306b1abdf37b91a674070f3fd52dbe4bd41bf Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 20:08:36 +0900 Subject: [PATCH 139/221] fix: incorrect version number --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a4ecf..1444e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [6.0.0-beta.5] 2025-09-12 +## [6.0.0-beta.6] 2025-09-12 ## Added - Support DayZ Experimental - Show additional client information in Options menu From 5bcc4ec76d32b90c16494d910bea561877d25a5b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 18 Sep 2025 21:37:35 +0900 Subject: [PATCH 140/221] chore: bump version --- CHANGELOG.md | 7 +++++++ dzgui.sh | 6 +++--- helpers/funcs | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1444e4d..9adc32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [6.0.0-beta.7] 2025-09-18 +## Added +- Users can save text notes to describe servers + +## Changed +- State file serialization methods + ## [6.0.0-beta.6] 2025-09-12 ## Added - Support DayZ Experimental diff --git a/dzgui.sh b/dzgui.sh index 18e47f7..33fb0d1 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -o pipefail -version=6.0.0.beta-6 +version=6.0.0.beta-7 #CONSTANTS aid=221100 @@ -585,10 +585,10 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["funcs"]="a286cc402bfccd39493fe32c53148a95" + ["funcs"]="c27a81577c818b4f4d786ada52ad5042" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" - ["ui.py"]="cd9f4b3bc9b1922bb10cbc0c579cf2c0" + ["ui.py"]="8d6e52b3e9032acbb84ba6cf137f455a" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) diff --git a/helpers/funcs b/helpers/funcs index bfde35f..e8f35ef 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -o pipefail -version="6.0.0-beta.6" +version="6.0.0-beta.7" #CONSTANTS aid=221100 From 936dfe8d02b352db8ec1d6ba120b3a1c161d6705 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 20 Sep 2025 18:16:50 +0900 Subject: [PATCH 141/221] fix: correct date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adc32f..edff876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [6.0.0-beta.7] 2025-09-18 +## [6.0.0-beta.7] 2025-09-20 ## Added - Users can save text notes to describe servers From fe40a5e54f69c5121af428bae40f047f865f2a2e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:46:12 +0900 Subject: [PATCH 142/221] feat: self_update flag --- CHANGELOG.md | 4 ++++ dzgui.sh | 6 +++--- helpers/ui.py | 19 +++++++++++++------ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edff876..94e5bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [6.0.0-beta.8] 2025-09-23 +## Added +- Flag to disable branch toggle for distro-packaged releases + ## [6.0.0-beta.7] 2025-09-20 ## Added - Users can save text notes to describe servers diff --git a/dzgui.sh b/dzgui.sh index 33fb0d1..658421f 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -o pipefail -version=6.0.0.beta-7 +version=6.0.0.beta-8 #CONSTANTS aid=221100 @@ -585,10 +585,10 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["funcs"]="c27a81577c818b4f4d786ada52ad5042" + ["funcs"]="65134f86b7c9e14173e05d8e3ebea101" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" - ["ui.py"]="8d6e52b3e9032acbb84ba6cf137f455a" + ["ui.py"]="aac7ef0f75876da82fba8be2740e22b9" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) diff --git a/helpers/ui.py b/helpers/ui.py index e8c4906..3df3aeb 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -3758,7 +3758,7 @@ class LeftLabel(Gtk.Label): class Options(Gtk.Box): - def __init__(self): + def __init__(self, self_update=True): super().__init__( orientation=Gtk.Orientation.VERTICAL, margin_start=10, @@ -3835,11 +3835,18 @@ class Options(Gtk.Box): self.branch_combo.append_text("Testing") self.branch_combo.set_active(0) self.branch_combo.connect("changed", self._on_branch_changed) + self.branch_combo.set_sensitive(self_update) - msg = ( - "Stable: only contains stable features. " - "Testing: pre-release beta, contains new features." - ) + if self_update is True: + msg = ( + "Stable: only contains stable features. " + "Testing: pre-release beta, contains new features." + ) + else: + msg = ( + "In-app updates are disabled when installing " + "DZGUI via the system package manager." + ) eb = InfoEventBox(msg) version_rows = [ @@ -4355,7 +4362,7 @@ class Notebook(Gtk.Notebook): self.keys.show_all() self.append_page(self.keys) - self.settings = Options() + self.settings = Options(self_update=True) self.settings.type = RowType.OPTIONS self.settings.show_all() self.append_page(self.settings) From 35f1f180d2b2faba61788331842fa865df433e92 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:56:00 +0900 Subject: [PATCH 143/221] chore: bump version --- helpers/funcs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/funcs b/helpers/funcs index e8f35ef..dbb6ab2 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -o pipefail -version="6.0.0-beta.7" +version="6.0.0-beta.8" #CONSTANTS aid=221100 From 796dcd5588a15ed1f65ad03f60e7b7a359486a1c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:07:42 +0900 Subject: [PATCH 144/221] fix: missing param --- helpers/ui.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index 3df3aeb..c4f2d19 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -1872,6 +1872,7 @@ class TreeView(Gtk.TreeView): ) -> bool: if self.is_server_context(self.view) is False: return + coords = widget.convert_widget_to_bin_window_coords(x, y) path = self.get_path_at_pos(coords.bx, coords.by) if path is None: @@ -3207,8 +3208,11 @@ class GenericDialog(Gtk.MessageDialog): self.outer.set_margin_end(30) def _on_dialog_delete( - self, response_id: Gtk.ResponseType + self, response_id: Gtk.ResponseType, event: Gdk.Event ) -> Literal[True]: + """ + Prevent manual dialog destruction + """ return True def _return_to_main_menu(self, widget: Gtk.Widget) -> None: From 54e2d2feaa1165b00664dfaa9f737426e8d90599 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:08:04 +0900 Subject: [PATCH 145/221] fix: block tooltips on main menu --- helpers/ui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helpers/ui.py b/helpers/ui.py index c4f2d19..df896fe 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -1872,6 +1872,8 @@ class TreeView(Gtk.TreeView): ) -> bool: if self.is_server_context(self.view) is False: return + if self.subpage is None: + return coords = widget.convert_widget_to_bin_window_coords(x, y) path = self.get_path_at_pos(coords.bx, coords.by) From 239f806f7e9e0e262ad7f25e45e2aac25eef6635 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:09:39 +0900 Subject: [PATCH 146/221] chore: update changelog --- CHANGELOG.md | 4 ++++ dzgui.sh | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94e5bd3..ac59081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## Added - Flag to disable branch toggle for distro-packaged releases +## Fixed +- ESC key destroying wait dialogs while thread is pending +- Tooltip signals being processed on main menu + ## [6.0.0-beta.7] 2025-09-20 ## Added - Users can save text notes to describe servers diff --git a/dzgui.sh b/dzgui.sh index 658421f..e9e671f 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -588,7 +588,7 @@ fetch_helpers_by_sum(){ ["funcs"]="65134f86b7c9e14173e05d8e3ebea101" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" - ["ui.py"]="aac7ef0f75876da82fba8be2740e22b9" + ["ui.py"]="840f6f97a27d94606c15b8925bf8dd87" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) From 9a37cebe4780c5ba2f355d2910a6d732724205ad Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 3 Oct 2025 01:20:40 +0900 Subject: [PATCH 147/221] fix: f-string in dialog title --- helpers/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index df896fe..a6a6b42 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -3238,7 +3238,7 @@ class LanDialog(Gtk.MessageDialog): buttons=Gtk.ButtonsType.OK_CANCEL, text="Scan LAN servers", secondary_text="Select the query port", - title="{appname}", + title=f"{app_name} - Dialog", modal=True, ) From 9d1ee7e130376233f23809f032e039d4f2c18b98 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 3 Oct 2025 01:22:02 +0900 Subject: [PATCH 148/221] chore: update checksums --- CHANGELOG.md | 3 ++- dzgui.sh | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac59081..4ccd93d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,13 @@ # Changelog -## [6.0.0-beta.8] 2025-09-23 +## [6.0.0-beta.8] XXXX-XX-XX ## Added - Flag to disable branch toggle for distro-packaged releases ## Fixed - ESC key destroying wait dialogs while thread is pending - Tooltip signals being processed on main menu +- Leaky variable name in dialog title ## [6.0.0-beta.7] 2025-09-20 ## Added diff --git a/dzgui.sh b/dzgui.sh index e9e671f..c47cb14 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -588,7 +588,7 @@ fetch_helpers_by_sum(){ ["funcs"]="65134f86b7c9e14173e05d8e3ebea101" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" - ["ui.py"]="840f6f97a27d94606c15b8925bf8dd87" + ["ui.py"]="4fa719725febbeea31473d929a40393b" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) From 89f37ab70d89f417b0d09081396e0eaeda70dc05 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:52:55 +0900 Subject: [PATCH 149/221] fix: use fixed-width property --- CHANGELOG.md | 1 + dzgui.sh | 2 +- helpers/ui.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ccd93d..d0c64a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - ESC key destroying wait dialogs while thread is pending - Tooltip signals being processed on main menu - Leaky variable name in dialog title +- Prevent extraneous signals from propagating when column width is adjusted ## [6.0.0-beta.7] 2025-09-20 ## Added diff --git a/dzgui.sh b/dzgui.sh index c47cb14..2935f8d 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -588,7 +588,7 @@ fetch_helpers_by_sum(){ ["funcs"]="65134f86b7c9e14173e05d8e3ebea101" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" - ["ui.py"]="4fa719725febbeea31473d929a40393b" + ["ui.py"]="f03879f088ad074f2ce3c92607c30202" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) diff --git a/helpers/ui.py b/helpers/ui.py index a6a6b42..8a39862 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -2576,7 +2576,7 @@ class TreeView(Gtk.TreeView): App.right_panel.filters_vbox.set_active_combo(0) App.grid.right_panel.filters_vbox.set_visible(True) for column in self.get_columns(): - column.connect("notify::width", self._on_col_width_changed) + column.connect("notify::fixed-width", self._on_col_width_changed) App.grid.statusbar.update_server_meta() From eeca71fc252ae334469c29b02c08f8a65d541aeb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:20:10 +0900 Subject: [PATCH 150/221] drop: tutorial images --- images/tutorial/01.png | Bin 34154 -> 0 bytes images/tutorial/02.png | Bin 32750 -> 0 bytes images/tutorial/03.png | Bin 41721 -> 0 bytes images/tutorial/04.png | Bin 623147 -> 0 bytes images/tutorial/05.png | Bin 582334 -> 0 bytes images/tutorial/06.png | Bin 427406 -> 0 bytes images/tutorial/07.png | Bin 228379 -> 0 bytes images/tutorial/08.png | Bin 12071 -> 0 bytes images/tutorial/09.png | Bin 31823 -> 0 bytes 9 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 images/tutorial/01.png delete mode 100644 images/tutorial/02.png delete mode 100644 images/tutorial/03.png delete mode 100644 images/tutorial/04.png delete mode 100644 images/tutorial/05.png delete mode 100644 images/tutorial/06.png delete mode 100644 images/tutorial/07.png delete mode 100644 images/tutorial/08.png delete mode 100644 images/tutorial/09.png diff --git a/images/tutorial/01.png b/images/tutorial/01.png deleted file mode 100644 index a8bb742ae1548c2456e4e18c97200154f85a5c63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34154 zcmX_Ic|4SD+a61jY)KNbHA%9C?0fc|gshQutl4)-co0HNh)F`zG 5m5?UJl6CA1 z+4tS>UA^z~e)D_&=$CPK-`90s$9bH`aokUWk%1;14I2#v0-@8^QZs=-$c-QnGBRp% z@Hf&8wBr!SGl;gDifQoZDk H) zrKO;79O0(7SI{J9>V9yeH&@7Z9W+(5+ebzLUHtH2fSV2fAIevjni?98YL0QgdPdtw z_6f?|Cft{m$^YmaE6B$e#nxxn*B_i*cBUU+jex{_eJuJWUQ;;ZV~M(h%rnOFl~OAR zcb!~w7t58&+Yxx7&ARw=f4^ksCh0W1W>tQ(xhw0ORo|4sQNESnhA1m7mEWuK70*>q zg3z!o+k1(RC10J{R_@VNd})0w%KD3}|6QkN236xyNVy6kMs6u2OzR09!`};@H?(;| z`1>ymyR)eVYoZ9RKWnwdSWMZ2YtBD&OV^Xru8i>fy647HPG@ JS|nsR}Rr*jNaP&v(+x|4ln&D(eZzujAA}tv8nmPdIuuN{QtkV z(p<~%2Tve0DRj?rQvLni@JzSYvZ)@8t2u%et^d7mKf?$w>|(@H;BfqZuB1uc6n4x= z3OUZk#Gm5?zw?;>P1w;!;Hcu8{2_@mvj5(MJkwOxjsk|^ z+ z-#(hKdb8On>E6G08}@tQOT^*!7u#K>f7kKL7>0pi< 4(YP2T?fRd+Ie&qYQJJNjp*l9~N4%91vgZ}U=KjBXqYSb5GKhJM zW6}N`_ZGd_?1f=dw!imA9U4kDkBCO^|x+= -+SGpH86daEbNrttj2WT9kq5)`G5vdAnnVA$ zdzH8GYdouK(+d=Gf1-}Jw*Or|3daYn6qwzn21U$+P{ZxLwVxxiO$z@jV~Ib3?|KpE z*~5c ^$LCV9D1aXioG6=cW zSO+x+wIGgsuQqJ2_QG{l>giQHjdvAp{?^A9)pEg$dvf$(@gb1)&v&Q-dsf#7cuZMH znBl808Cu_?b`hs}U9bYTqsfyt>>06aa%^bLT6p24zT|Y_Y!gvpvN+Uo-0I+l5!t># zd9skNaJF!^f~6}%pG~cwXd_J({}j%?+Q>+qSa@2W@j(1+`E2j%ozpLaV!Ua8+7=Lp z>!*uKIeB?!+h?10YVRvz)&|ym>aAmJr9^vzc7D7f5PyFh;Glmbka?r`&&tZTKV4Q2 z`m5O4Z^zlQF?=0yz&sLa$^3HiZDw}$>^_<5N24E}X1FP3J3H^xac5)xD;(($1Ttqf zzkf^LAFb)FKWy98sy0I@taUqx8bY;36%O_|S-Okw$zUEKwzV$>Gkz +`lAG zNliZLSqrxf-#c`{qfCEV3nMxm&CO$6Ilepe_8aC!?w#}(N_m~`EMRlq<{(W4QX6rs zzcMsq6we3tZ3pgduWF92O2)5)+lOkmY}H(~$Rf@4i{yvxy~QoAtgkow?jFsq4Yg|W zUgA=ct*ov-Ap}jJ2G&RwQysA*9!Kp%cCrC8!=cd7!=E)36`fiVW`!``v>*0WoN}^a z(mne#7xAX<1K7h&{N+x%YPbQacR3(IZR=^!+4NL7;p`ywbn0O9My4-u9=*vf)v+*t zbtXZr#`1EKT3}; aD&x ipE-u8;$+xzS@yYm9JwDQ&`=qp(n5jV4M96xK)I|9C#kyVc38%?e5fSXh*df>H z)NW~aN(nyS*2=037fDN1b>Rg#Pt+p>w>r 9US~ZX0*mo(8qg*gg6mwg5~oE9Qu}bRimFd|4B|ZQeJ&Hx&I$ `r6uoE> zXYbCeKN%+33ry3-7JbcWGORul~?h2 -movXB=Owm-)=Xs`HLF4Z;QNCa# zXDJud6{nR=Y $Ulwif2V`#g>J&DTMiJ zw=Vc6h|%hT1?UY{q5d`NxXv~qNuW~jX3z6x2%X3%p4Nt@yL9I{J=)m3g)kS$w0d&c z-Q8WIaC+G;Z1XP`dJE9 Phe%Vt5@(kMmdFj#Ivw3kGpI%^iRk^47$zM_{k&&6pd&SzLqTk8De(4d z8tTU_MjOWzQeMe+E#U)=P0x8Xr8%}3A`eK|?bgZTovMpmktd7wCHTEve>Vv>h%#^5 zo45I-oqiFo3AV{3ziJa?)=#HcRr`uJYP`l379IlS<#jv#h%*yj77@V^GKfRk#_oEH zr1Wsd!e*O8FKY6{vdDzAj6J-~6ii7b jNNw!V{?~|gjvl7Svftv7z-#LwS}-bZ}0Hj@10wS$h%V5XH@zEnWu(l`)83p z@6O6jQ?a*Ml3eg_w}Rg?Rn(aZhzN&Fo=weK&1Q77bcin?mX=osKR%>weeuoxYD?3} z#CfiG)oB}gMC&w%lI;7T#lgWFh=@%m`LtoABU+>RZ2DI8z+=8V{i1biD&KmKV4taj zinKwqRNIm()CY{;94eS;W4$NX&i>3AQlNPJ+j3sEpW(5mQMeC%QHN!xQt ZVB2kK7rI!B$IiAAY!?U% zP0hEIr&Orp !Vo~@yvDtdYbWo4Q?V88m^CyzpXpBNV3qAoBi zJS9yYcUR%_aUaC{#r=@}IrgmCdQ0U5Lk_vo`KMKa$DnOY&Tba>FLolYVQ_~#4@wYW zdt;T}#I4NbM)?Hp7gWt_gtCZPfU)Nwzq&-!N>lnI&}Jx0g;K7Qi68~x1;fZaT=qzP zyUi%Uvc@u5mB-FvXMzp!TD!>09Jh_n7iZfk@;wA?By6vLo#9uP5H0;q^TC+7ys&p? zBS&&OQ>P=+R5ejuKL4MN^?#yG>bH7tAqHSI(*@BE71E)8zt-*Y@<&!OyPQdTsL9}^ zl|^5qgcRnL7T=?|hsf7x^cq)n1DXXV61hv2Xnj-L7qq7M$unEsq#NZ8p}R}21x8RA z&{y0%1ERK hH;OUTOZR+bY?=g45 zr1usugnhfS6g|E?JByrLEB;K~aK%a2o}j?$plZy|4}I7prO4l|XLFl%lOe0@8ihQp zv=Mu4>WZmD ;+M9j_)_q!N&Tuwj@d6m=7dOWSh)Qo=4@(CvO5mBy3KYwg| z;UY~lD0r0wg%EK`@Tw6Gc*nhKbR~w@*VmWRaE{*CW3rSRZSpK@=9niuFMiFlC{w?o z8ut=E<8*`YxauiIMr$&DAHweO;won^KR}^)Adps3uBfBKAS_9!#*zd|A!-vR#dZ-> z(||oY3R2^p_MQkoPL`Uy@VS&6ay^jnmrD`KA`2^=>EruHCia@Wqmnv^r}JHy$)nXU z)K^CBbRqiVza`w|-P8nM+dOQV$X^3=5+TAIxrf1?&Q*ypB|u*Q%mOt*`nrx?+pI%G zF6}g~M^$SE-MIK=^ynxT8!_aCu@sv3ZrbwYuV%5JTQjxgWkE wcy^_(voGc0$WnYY{ISrGL_QCC40mq!|fy>F!h z=LWo6eAmXZwItYRWBC&YVfPI46vihe0=EZ;Cr`JMJCTd31zPDJzP>vf)x~3XzEy#i zY!@DaG@jZFmX9Al?JPlHNo(g@t|-0up77*`(#h9lVV5M8vc}NWcDeNkX>mDoVRLJP z4z9CaE?PSJo&Eijr6F(8UnwPeMX!_E93Wp3qwc{)R`x&DTK*Ej9=!efE^42Y8o51B zuq`XIMEllrG yR#Y?&sBIc7PTl)DoD9Kec! otr%b;;xt34%2KezfTJ;Zk>-U_N&o4_zTLX3H)$nF zWHNFgW-+_o1gXaOV%$06uxP=SG&pqD%@E}sY5&wKlFabhcll867m)HlT`Mhp5LKUB zbbI+!`imVR8 !+JsBo%MNp}(gAd-|v3@Ou6ys+iN?owL@$>wQFb?8yN8uBIcDE$pn-P};g1 zanj4R;~%uU7Z|a32*(`L&L(`1=ZZKQS-AQNR@uT??|*2C>L9I%8!r2^(Z@HrJOc>U z6nqGi%}{uHqJ`@4tg*B%WuZ<`tzb-QkC`sVFK{8=0GRn`H4ylX2)o9yvfv|)h}tBc z=ZY}7no(hK(weFy&bl)hxyw#LYQ{#+qQhH$5Pi&I3NG5&9{tI{Yj}^T>ReDaE-*Ui z%XE)u9tU3&%Vutd45kK+V75R9Hq^(z|CIzU%U2BeM)c@(to+M52HbP!*T8-v7e{K% zQdP=s{4>r;0?ZdbOAe_V@j#n-9__9yW=Q5{wz}CWPtBvBGXL{^VcW%d{zmVxNYw6p zdeF5;?(X+~Hm)4cd~5r9YFpULouRYd(l>SR{Wmy6sT{wEzQkq*#iK!~7Cv}qdP1_@ z>5_gF_p|Q#-QL`bNxB*_6R;|71POV$!_sQJbFMu#YAY$!PeT9L>?CSC1ACp5ZKKY{ z#Srl}FE5^wCHRddK60LU3}0K3IUf9roI&;A#BbqT_~{bt&){&d0-|^kGZ_Maxlp*| ziR-)d86qlooamzwcit_hCBXgt=IR@#5k0=+!`P%`a?q6N>GsUsYbrK7H7NL6%h}nH zy700!^{Iyf#APWjA>k0W9ATCuMpgRw*V;}rMPm+P*XTllfOtU|UR9 b +@$0 zX%>8NrviD%`Rj1>{HeTHG!`L=c+3{e>%L!2Ze${A*ke eJx?tIx$$B#%$`uK~GKg-MY3hWnRLcXuqc-<@7uLGJVVf&2| CDYzNHaE{5eeVV%8YpvLgy0sQ z(uGFUo7mD(< ydpey zT;VNwHML_BoPCz~=Bh#x5uSgM(rjJQ!;9r|Qk!qKAWKpiy3E^?7mh=t)#bh5mguJD zix(mMnYa REOYyA659&1N2X9`a00OxxJ>Ng4mA_DVgz z+@i(e@I tT} x;16TiySau#`Ea=Z86+-rKG zsX
K{F@DwOmv_xw#Qo{Y<4PSaB)bHh5tbbHvRE7YO z+IhJn=cl8e%yVw7^m0ofc}|h+;g5GZh`u+QYB_DKG@~oU#r?{f_mT^uqtm7f43&;~ z?S`Mq*IRSKaddI2@G|R*>!M)*Am+E#A&~giYk!s_juyr9u6u^&rL^*FAFA@!UrK7@ zO>^}Qf{%L aWyyP=k)GGi6hV)7xQmwCAup15+wqtKLKI?as2ptnH zGU@gC@fIi|G9g b?A04|I|Hh2Gx8V@ry zH>FeupRirM8On<*+e9F9D*r~t-8b=FEsn#S{Q-OFR8yNY^3R{yMG 4~K91T70s|*bL@}O{Q@xkydrp-*Q!9t&K6R|B$`g8Z<4m1%s;azM9-eI3nQIAZ zydi)uV2@YLFQgxA%@C2uY*X0>Kdy?^3qC9ar0?k@IRnH EZN>d7=DMD1~d4D=<`CAOx;t2J}BDx58?z!aED#qIa_xgelkJoe1E?l zwJs-6vs9R>NSZ2iwz?z6ZP% Gww{^2*Ls-YC zr9xTr(QcuprfR_kVZCL2|GZeXhcE)~SS3)@_il)=T8j>=5NuU69lx3R^F`}2nlmWM zF16FuI#rEb-B{5MGSyAU)PlltnEV_a55Pb^zgbt};;eYEF}u6#DhUUY6VkukzKWi9 zrxR>K@>+zd%VnZYtdojZ#pa$I*PO4M@T2_?f|@BZFF8Ikc$YOy&7REE6NpP|OOB5= z00f-ut?t*dOl= c(pE2T!KTZ;^(#eJ^Jh0ht_oWp;m)?ClIeLeb#B9P`z9A$MWOdH z_4poeMQzpN7NtWS4}kP}Oo4jmg?>T?;m*a~%mC6ctl|wuk|(3{vzcyz#Xq;wKhB3{ z7ss*9!wM0Pk=8<)LChH%g$39$sqC@3r+)HjW6ns?#P`?YR8$lK6h3(sxO*tnq#2Zm zYh--&5L3~TxP2F>B|H1O?GBqk6UXnYOTnM__V@WR*u7hJ*E$f9`JIkSEq7{kl5HO> zX^#4r2PHE!>Kg{iT+%m1)!Z>J14CJ^UffmN;6P7>%Ko1~GEay0!XUX^rjRMnu&myu zT*01oC>^$;HxnE-yE*Do_9W@!DcB62j`su)?X$9Vvo&sys41O`u}2N`jSuiJ(fHMe z7n)q;IhkIMGUUC47ZHruTD)p8Z_jyo*QJ%z`6+&OA?z(*%2m5yGw!K{?L!d}+hRKoLmW&PK$UsJupw10ftCe9v+7*bKk zYoSnB&7%~L1Zgd(q_guvrcT@AsL5j7hNMoB!#nJdhZx^XXS*ore2H}_kam@+Q&MP~ zfd(xB3wS{GoIA9kF)p4;E1f!iD6y?e)H2KI5kxE9^<}j6=J}K8ch-FDoJ&ECZ LNi0T>XZ6``%xznK|10$zllM(36-^$d jP2PLn8h{+ &ipFEI6 z5xLG<&$gpTy@t2=N*-l-J{`6@n{WpJoZJY<5mS{&CyP}jUC#Xo(&FS<@8k(>S4?1| zB~WSHS3UEacdE?L>QA2LnaTA`F8kIB9-BcR0IUJk95BavVXVj?l+ap@kL!6lMPiDI zyMx}7$FoXD(`FA=k3#2?L_~J?{ONY~PkVuU4F+*r>n$-_cL_EylAR3MhPId>$C~ab zN-7>Q;ti7D3){4ePRw7s#u>h9W#{d_t!H3Rc0e)WK@S0)PF8L-KK{r?xCFm|4PDi! zuSDK9sj) Xhq8Vog%O1hiMeNqJV(W?8Y0ztZv8C zJvjDKg4$n+b^o^;y#KDaxRMl>F`@k@G0}*>+(PgAs;3vc->WK7?R_UQdof!^qJodb z&{SZc*31wXEgAwg0-4n0wE*M8S7V=O#+?gSggomYuHAeUDq?-G)hE8t4aVQdeYr_q z?HMpT7(khz+VphvC(~Tvb|w9BLxbL994J(fbH-Qa8b->uZ*n|$!mWh(P;Hbv9Edl! zC8X KLYUl zca+1Pg-upG>e5W5wF9>?YB@J~x_-9FbygT9Dk1%DXnAH6s{R0$sZ%y&+Zb7Rm-?0c zn*rsG$+M2L6PKu?{Z!6tlDRJvzEidKz-n~RCgXK7finlaVSD14I@ry5Ley4A)Nud! zt`dpiEJA{ lAmhfR&v#lH*iP_KyF<}mmj0Icz-%qybBV!q5%^Du z`x(&NOf*hn*AV!Nv4fF^%TglYAtMV`DsnAA%K>^`oBdga(rGKO;)HX%64X?aEj`89 zBNUYoc(~U0WEEkCAMF}TN+%uIqbem*#zcPU5cYU CgUUOKDqR5xn1=T3LSrbXY$bE1;q16y@lY05?zAy6G)`;cVn= z7n}A^n-G1j5@VB#r`%Uj4}Ol*Rk`)Xw?e|^y0eCYxMC9|=ozKnZBcu_kYIh*N&~ab zAx)QnqMHOS9plK=;^R792>wa@`F(CD6gs+n7&~1+mfC^thpF-Mv{q*81v&?wws%$J zV%p7PzP9h0&Cg$rJl+fApkMTO`cE4#+Uy2P0`P+**hq``C>bfN&(YlGT4^_^>F3;i z*(>OZ1iXkf$Mxo#w9#;zjYG53Z97uH<9h)3eXLON+>a|P64eTU-uX2XHH3ewc=&V3 z)!F*t+}1K^;?T`Six<-wFo;@6mB6`0uSjyg|wIW^j?OO!nBDocugsnB-^E#Sc~0 zTaPQq+?64DpUzBT5583a86=`vf%g7YnzqOMCqqg{K=P0}Jw=>M_ofwq-tt!r=E{2D z8EL<1M-n+Z+Ck3VO5qBSv6A)g_vywez>yHwq2Gdb7vt@-y;h(s05Fj@gVfu{uT1 zTc^22o?K5e`e-U}fjoWgzIdwx{WZcU`}%!6!RD;7gQbCR%=xp|!_gfA!L*ilYzf{k zO!=k|EH+HgRldjK{D`F-)5- K2|h-qvBRe709yh zxjj05vsLuhYOLXf@>%f|r)$pJCn^rO!dFfsp2rY&UC0;mEV5V6ti}=rabu?!o<~^p z)mDeOe3{;BTYhqGdVIWYpU1<`m+a_z1li+l`AP^AWd&~2D{Cz9AB$q!*8KDKjqZ>4 zpSm_IvC#}2$EBPF!Y!yfi$6)0oy^;Ne5_xT9XVCK8Tg0w;)SiXuOoJf^P;o6ntu}` zK?FO$Y`Kf8G;O b~?7^bnKVl?a_A>X>khjY3)C5RwCVX@u9MQFS}WDvIU zJHBx%NY76a zdXwe9`4yi%#iYj|%F&t~mh#{15GS405UF8dkLPDoM{^e6e-b21D=mY>LW0A8<5BVC z|He}IgMUr_H N*jwaGHW35aCqt*ZNJ@uYmFLD -;@2c^=~8{@3HlV9e?KhcZrPxv-#;E!;MPbE;c^i z6*>Rr$dpH%EDsbgQ>8l*QDWC2kj){ce SEEmIom!A zVJs)IPeMNDQ-$C~2)8yplryD16R=bZ>V!CsxMF??kG3zj{Dz^J=jxXX3jKaToClVd zgN=>)f|mSezY0x@EGO0+C)VfC3(v!$bd``Vnm%{sjYO`+* 4wWkI`T zLp1(nnD8F$9!qO|)THf`BZx%PaeT)tX)5(p3u!7wyM4HTNKH;wcpUqey~D(^sh_{e z%Nv*X$qJ|+n>-rC0OQFpYAZueGR#L4h~laRJ RE@vAEpiUKs zXF1(Y1WFIMN lhe&i9b@KQ*U%ypWD1wkL~rj?($Q|xRguc`^{RIe_#s%0Z@oM) zS28*^{hl4i;)tJ2KV@aHB(=RaK~j>FLEf1lKbRnEYVKhtxl%h&1F%`;3$dy(U%At? zvf%KfwwYN)@?Sg)+$ +w)!Z_4V!I!@a$C#l$LWYgwo%LHg#gXjqp0@z(0SrNFMk)F1Id2u8}@ z@$s(8m6;ibu&_omRE>q;ojZ3JEJ|$4yel85P>W^%>dKXJuK@<8LQl+$Z@p~Z+6AfC zWDuRAzF)t7ot%WX@~fmnoy4;H7yV`PoU7_fN^UVF$WPt*arqzBf>&D5^AR`YsrfQ4 zXg9h_G&D7FBx|I#^D$EIgf`=dVyv_sDc6DYXj_Jfd@jL8hrmaTMRYu7Jim8HQ4YeO z7a`#$l=;AT8u6fQ{nZ6%3NMB*P9U&TQ?kZ|4C{aOmyK8*t?7vPZpbL14<<%5RkN!E zTT&Zq+~VS5ud9SZnrPnPrwdKDxG)4EIIe$jhlqm%Je$3KiI!?;Xb7LLfErpR5D4JU z2n&TPet$-iyn?tQTS#5gVJ4KeE-ts=IDNj1URQrFukO`Af`?>{<@MB#pwV;h@0GL1 zRZTx>P%ax<-VH4<(qepJiT3jLE*pU1a5xAgLCvAY@^<3ZF=+sn$t{VpuVomA5Kd`$ zTusJ? oyAi-ZfKj7^w(f8aGfH) C)OjzZ zcHEDT)1bZX-Mm|@ksCT7EgclZNfWCYtIETaK>hsV04!TC4w_5x&?1Y6ntaWO0p<=z z)Ly?vhVT|;tFaW)P6sI`FN|(cpBA+8YId}uAHaN!pGEq!T?AWLW!Yt+jLtf1FF_XF zl76envu}UxlUX0>a7C` *FJT z_5#9OX^iK`dN;-vUn<{U!W?rhYkQy16fTh)ax+%f)KmyImX|9Vn}YDHqnK}fQ)A b4 zL~slrWeqI)i!|>r(O4qQ?ld=(7Vro>(HDC(P$nrU$;Qr fB_eF BszCMGWoVcO?&S=rFJ zd?Wtq5f5RwZrPA7l!=;0-5At3EA3HuixyP1e`hU5BOM5ifwFllN$N;buou)kt)gH* zSdv1TCyHIhNYU@j`4gY}i2swu{ZEw(es6D2SWr-{uy Es8OggeT4|fm);R6lKI3Wh%z0D>~-zIf!M^Hk93iVTSSBcOH#pgcKm@KUSSd( zZdut-;?GPC+$WtP*^n(qclYJYJLaX<_0I)vQ3GIo@AZzKf!ZEvnyAKWTPDRw&77oO z*eM|<<{-){%|Q>xU1wu^&%cx;(lTr_l?Fu)!14@A24DbG_$SUMAO8HQ!_D|(Vf!Nk zZ^4FpSrhmzVe7gtm7fW#gNy#;(a(zAm}w-Gln{D+>mlz^0|NT}0E+lBh~OX+n{B=M zGJwAIphTxgK49s!YJsxNLxD_Yb<)ugR^uu~<#waBFlr#N{jJl8Y5^m#_7xG6>)!s9 zU?JmMd;9v{55R6RU5Ia0A*qlMxw)V&|2%#uu)E)~uRuLb@Wy&pSiChT0hhfc!9j1R zrw7UhjvE*pjH4 )IF4@>9w?v=r^y9Y< z!@0|Uis}UgVYHx@c6R&66ad+s$c} 0wez1 zu7cuo b4bu4T VV zw_i@6d_k$+D|qp>&cM{vvyRyUegT#wrz#o9GgE=yMgP!dTeR6^!#%U1(R*xN;7N3K zbqkD?Q!P1D0q*UDM%Z+s5S5|*$u8wc!Y;y8U>974uv%({O4E`+;4e`Ym>@kQbInjh zpSp_|FCtAtot?AZyC$_+3$l<-mH9Ff+GZN9TNzagdnM%MJ!&k$VvDj0GABZ{K|2NQ zSc1(-Y~=TEf3T*hLMn`&o}Q`&A5CtbCnw8s1}2kj85ak~oNq{B;gEQC*!K(P$x#}G z#$`kMyStsl#N^~;YhiOimSvTy&0t lr@y | c? z7_V`dV3`qr%5^zDI1ZlG1pY$x=PPAypT_YLgS=|O>iD<~T-b)5lDyZox~K>kh@duB zXR7ZoCn`?r0Od$m7dJc{Op3^8owg8kPSave?I1-e{k)s2fBpLP8q3xBE-SE8TC5i- z$$>Gq^XF2t?d0H~tb#(9=u7+p&ap4B@jjFsg+dh;=0@j0wb_#6zJ4VWhReyw)R&i+ zS5&C czs6 9>AV_Cc}^)6Mx<%O@Wz^9`Ug`18!M~EfMqku+*zL=9+H4`SXwE7 zYG`wK8XD^S^ywuxPS9m)2KomEh?~KQZ4QbEkxU)X17|m-^Sb?I6%-XKnwqTnnJLMm z2Vkq~>s6L$24D4>+R#_Gy N!hZt-z(plHIvti-Zu<6DuYI{eH_yA2@v zASVa9b5PkMz6^lu!ybcbymw_}YAP#D_?cgIHK9;d&CSh#4&` 9hfyrhnkkL8kdkEag zE@!`b>%viZr(;KZyT2^j5 z#QppCH-o>e@873>DDcqGFx5gZ|IOczn)&U@I~)q3--*5f{{AnMlW# hH%7+>;>AQZUN}5uw4CQ9oWfpgh&du<}iwF|{qQ zdHih?$fE$;wavkq)fonB$gi)c0C<>Z;9_pRu(RV&+EihD(Z6VldI%i;pP6?OzRxFl z)?3?JSQzm$^JP3mop%ia)VHtC#M~To4G k zkx6|E=LnMNIrpc5#G%HQE2>&rRf1N6Eav9syP?fyD0SP4Fw0*(X|>hW)Rf;s>0i9U z5rH3@XHaG;V1j%-wCu_Jh4EXsq7wLsi(A2wuH+OidKR|z_4Rp+E<=D?S6o~iB0n)T z^=<%WV?#$pPL{0UDVcjMZZj0Vu&~hkI1A^5zyk~hTwk@I4tpmNKbzg 4=TuOog!c) z181Y+&l1c>WY9Ri)k~E1Cvgj)0Y9^<`R!Iwu-^bq#B%kWt44^vrJoPDp)wo>{LDH~ zCX83|0A25&$DmlyX1Q9>Q8sEeTB=*OZl&40e*Y1PT>Sb~MHrroU+|u2dIVnsQ^jVm zHE$Z=sezMaYplN_whqp5(wL774_nySpj|2j ~ocPjUxgU7kU|B&}Q&kd)A%u z8`|mkg(Njzz{cP>GKlM9l1 zZ41dv`1N>vc!=7z_mo0X2Bz-H`vsT zwML&xb3`k1Gt+>erKtcJL|KcGaxA8af;<`++4a_?#o=?gw3Ot!y0&FQgb#wwV%e?| zgKutwWCJWfGS|k=u1r@>t-`|EF4a-&5oq6LD2nK3)}=;BQ@}h~lJ0fWfTTNHa@*|Y zF-x#Kr#T=?zV+Y|zmscfcEekg87WN^_AmN6q02Pi_geJp#p&@?)z*56W$%RMsquO; z2d8OKHj55)I#;>wqaU1Nij4R{rBKE2)L4Q)f+T_-=mJhUMc*Fx$mI!{p*$TNcv)%J z))=ZR1j7}vrUEt9)vU>JLbL4@5J0f~4K62q-(4QkP6w>~k5Dvz;bN>RSn^(1t#k H3Iw8^e#?Nv z`{3MrxMn&uEG!HR4-FT1UeLrocN#(8-4VorGFpj1zcoRcvaqo9LfzA}Oa)m& 1>nF5ge0n7T1Q0WhmT9io-LEP%cfZ6OE_O|H%4fFb}lN3m<5m$K5z=9Mm* zOOU0!=jTLVbZ=@g_e!99UBO!_7EL3U3yin{ZxMUc+|tYu2{Jkwg>-mV;K(Ju2lJ*P zLYGe}y>o8xmRpgDr}eiOZfdaUjMNSl!NTy#QJo5lUkiRZMXFFH6=1r9AN|uYYc)i6 zn;CG^X~pgvKmdtW2E*-7;=DORr>IhQY<*3cTAddZ8-QVg28B} Uq;?MbRQDFhI z{18{{_Q6JF_)p>_U6zx0Hf?%;xS}2J-<(IbUh&W}5TFfQl9a!Quw2}A53ri^m8n!` zpxyDV3CMtIGrq78)Zmn1zKb&A2XClZ3ty&wK~BciYzhLK(CZl6rF!cYq;pPj+G@e= zGl(;Xz3Beb-HY<^t ke20T zcS4putFxzP#E{NA)q+23Yht-we=dN8Fjyf)M5;z17`2?MR>y@*gyCrqO7dGP1zSaH zEIqH@xz^f~jAf#Xo;bP&Rs|NnMH~9bOPEZXQKCy!&kyv)LdJsTXJ#iJY#fdcix=Y$ zwb09J68fIU 7qmAeRp683F;*=x&WLJCAlZ^1-bTd0WslY^Dm} zPN#u1j3#0LJ0tBlA7yndY)4|TXmr=?QxOpn0PC@;f8F*$QwRiDyGu)K92`(;hma5% z4%<7Ki9}y;|3=I;=!ZUywX8YCZaFC(Po6xP-IwxbO88JxQi8$67cUZtL@;a!WC~>Z zB2D8M0|NtxJ!&JaM;4icFc#53zKGHS){aYu# WR!Vo_xEtEw z0u07M#;AvyDIE5!ds{@D+@pKWE-nh37*m0Vr~yIT8n0TKgaC`KpPygju Pk{tWoJ!#M{lp0t>7Z1~GT0u7`#q(KwVr-JAi z;`Iy-y@iz1Ka6_?$y$wh4FOc@r7cNLrUNIW*ENUcGNru)TPlZ)i6D!x^?@%ozX?{; zP*(>eu7dtW&^B;H2Ub1~dMU-LAe(D$ek;E9ku7)`g9zixKq`%@@q$UJDZ(MB(5$dW z@P3|wCZk$^tFbVAf5(3a_Iu`=Ds16`y;$~VFgHPf_~1X=?kWr~8!9y7KL{rao)e{g zAPnT0bPGXPIdXz*#1g1iTc|4_KwQDkq6^-5d7z!91u|>7Vvn-OFkN2Bhd{M*>*LfH zFEY{QP4l9=kfsH-v9Sql2ARd~1*?%E-{C*#k0wZd=0w86kuX3OBYrSyGdwL2d;Ux| z&lbv5`uVeC)rhB!|Lz0qLvt_Qyl(K~VzVt~_0tmy3JRtKX--2RiK%M&2L=L$V<8y0 zI=Tx!?ZrYY;wh-_D>3H}^}dnxt%j$!!rvn37nha*;Q+EtNJ#bwed2p(XJ-h+p`xy) z<_f5umv{_0&+kQls+g7v$#iIAgKS >VL?ZF-(GRDiKqhV^fvZ7#xXg`%<$ zX6Rz@4w)tE;6x~%&py o$!|h(q9UPOdpU=!g)YNR8>8B{0;%_G=)9cUuWS- z%~R$U%@x}1XjbNS_4HKmouigDnG* 4H`$>F*H0dEJmx=CRO3ewW=Oa+WN9s>k5G&IZ@?GOdZ8hBsExe8fTMIrR`y{W)? zsw1Fv=I&&|YS?JwfTj<=YiGx0>6nBg%5tQvt#KBaT%(D7-^ djpOGj9sH} zBH}CYv1oN|EfwU?>fcW$j`+h4bUZM@!z6RT7&x@tjQxT1yq$^b?-vL2Oi6#fY{1fD z31Vq+vDOkD+RPboYx?AHTQlXQ7BoHc%GuG8CF$jj @Q1+8_1pU)lX;s4K*kAex@F zAC4xuT`-JQ?#m*Y`mvVmD0q`O|6E|>fEQ-%?(miE;kwkD;`=ArO%X+0gsb>*FSr`7 z{UZSK&hTc?Rc2;pe*E~y1nDom0%SZmF7otn!BB|=)JestQw~cHZ6MPTeZk0m_wHRO zsU(39ZUUUCR%o-r-i?_g5r8e2iBOaYQbM9bQql(X(A+Hi#*J1`yVk;GpFc~;?CBQG z92^`VACv$w35bDU;^9vO8Y*b3+>C04#u=Sz>68fk{q+OphlJr_QMTm3zfss*8J($$ zM;}3^lXn@2jxvEOZ@xYTS=^m3%28;u%hZ(M65NcGF<+O5%7$Q#*=>D`zsJVLdU{^9 z+QU0P9ERuHvsPFLX6jsc4$1@wU{Gyg_-E6-Q<_Mwo9k>}bc&j)tNEEOG=fhwcVxt3fz)CgrsYG`Z(?RhmYtlnA!;R ^iJ(?cXPYO# ^B0$w(WU}`R0^^HVaHVhCdvxVxrN}wR!j5l;~+fN?+O^#fjS7X7&BL~uC&Fw z)uC~5an#Qb4)IYFe|~tlyR&m}ykI=tDN)idG6`!5y_@=06Z_9RePm0=eCzV+>NyB# zM~_A8bnnL50~P>e5orzspv~vdvCza$MC^~(*46?iE#|9ox?_ bc_Nj#;>f9mr7$ z2?=Q-!K!6sWB^$fl&hOlmWQOAESU5(Y=q&xu4=s;ppkia{94{^pWSTem1CyS2hv+m zP+_@Mb?KsT+SN|SEu08e4aNJ%Ul#PMejsq><>&8(HUsMkesSd#6t(LJ01@X33qW>2 zg3z}fL~WDW058nU%mhsc_=&6%jdgYWnL32g(GMoK)s0;{A5~8ffLJrw*Ei!Y3tS+! zY-?eu|9po2psZ|cNC&g2;B4vfWemWaSg9g>_xg%-qm`!s7J^v~Rs#-#@`2BeJQRy* za9j3mzK!UZ<;mDx6SupYil7>yn4bULb+l)o==x(aXJVVBX83JtCHViZF#KP6ZyHba z`u>YAQdSa@BqY_UBqS}AP-Kb-waqe5WlAhFks(Qv%prs%2_YmQBqW(LC6$@X89Q(7 z@AvnA_CK%l;5<6((azpmYpu_HU-xyrr~Ah(kz8TIjpno-6IZa!H~b0rAG*ix*P3 lF8Lty^SmB`K)oXl-S%}i6 $3S-8L*ON)e+tMz@D!}nf+~`I3r^=XYhJ{&B0ISlil5>DZj>Zy3(nF9byX&yZBA1 z{u?60qj31@r W8L}5hsPF(X> zfxiC{TZrs9MwALa&1juHxcg2b YUpB*pHTKpxs;xT5j%=K?t z4y`kPRPIU9=SEP3^lXb}eB;?M^KXKSmEXp#Tav8@8#i>h{iaNh`?&v}iQYYv^RLP! z)UUdcwXLkhEfiLf;*$y2+mXw$wD{uf-<+P5N7oKNZy=XkhpeSncCgFF^JCW=3G2`A z)<3yVMem}*Xirzq8#;HwfAuOij0*cEo)6-S7(D6m-{)&&_vflYWtP>8z?Fd6c>d;l zjbjB>2bKTE_mJ%4$R#%H6MM_Jri%hs()j<)X_@&>?W{a`HTYjaPl8^De)IB||0rq> zZFDgl4xFs)n%voV_-Vla)miLc;&lJ~FXx>vP4x!ql_QNT0|J=tlUx@TJz3(hN3l23 zIrrVF=9=jJ(`Q9tqoNL}m>eQnbFoxfb1_McIY=`;W2CPW |>ARtkc$jj99mvbeYi>Q$}9WNgqRkaW&p`guDCaEs6PcbWk}6g=^( z`+B47zA({Vm*a5_lCd9|XrRJxFb~IoIP3`UmUQlRGR6@wg)|yxlT;-WEo@JdVb>A> z1PI6p{c>^f(yPE3sebztpo`7(PI6L0 Wo- zf9Y)@asyMp2PA#R_n!M~TZoYob=v2QYL %{@w*$$o7l6( zxj^^eW#if@`u98Nb8Y^v6wU1T+}$+O-R ;&n^TO9#Um8k<=>j;#h CdUWsQ2@CzTJ*0I&jMp^*tAPkLKh8&}Mx8JdcLsh2tp;f9SEr+a#O;^X7} zrSYF`r1pWsB2!Hv@cM}{hQP{!Ya8BrO;!0X9o`<~2Vb7r;Bt>_mIg4jF6#nEu~G<8 z==?!)R0^WANNG9|&(9j1nw=e$`~*z^==_&adII4JkHN%WbM_VmkS?ZmT_ri4cOr=x&5GW)2#l|vYZTotyi4H;;DVUHeWamkia0MZup++ zAIiyBd1UkJOV6ii5AIJS`hbeu9wc+oT#ya(bM~91r GR8gOwWj1)bVpgbCTNcHzwAB)vk!y6 zH1su?qMS~QC8=7Pnf CE>3WYR`qDZjEx e{g1gI@z^Pn5h;cBmMb$;r znaxo%QBzA32n{c2y6LG5sSKk}37;Xd@27=uY?9&0-p#kBV@l$(G!^<*&zhvT+ofg% zl 9bM~Ug$I-cwIX9qyEObq@cllZIWdT>`tBXA^7XWx@7E4W{_?@ly z0V$&4#qS@-r$cVaF*A8y2sWC0SdtdY!O^2rs{+Ywm;xJ}KXZ`Fx 0Vhg zVZw?$a)2XyT*AQ0;DAV}C11xp^NAClwnr}b1r% &JCA|;b zdibFWApO^KQGPdtdeIQzr$-wL^^2h(U};}&%UBgGKzkPup)7Q0*al||+=dUNHy7pR zPLCS=DZZSi%H#1PLh~;M^t y)I z1Sf#Mxvj0GqeDkS9Wu` +=mu+E_CD D$SlhaGuXbS_`U9xgfAuU!C2CoeC& zW`K%-jS8&+7Bvm^+&(>lc2LKluNn@9*K+$|y)Xyy;dS(=D6bJnUexb+mA|4rZa=iW zaLnD5%5LWfl9{kNm~2(7lLc7;N$*I6c?`xW+uqlQnS(xU*iGHh*Ei`<^h!^F@CZ#j zil(hC!)9?}lFUAN{s8k6D2w?Oc+q_O-{-5%tnjIw* z4e-*_)6>_NrB%BB9c2KzwNt{vJLvqseED)mE=7Tq%)=XVKG_Hi9k^ObiT=7=2Ifn! zxG^zS_uuKF;;_h`BQfKE36fzK7Jv8U3n(gaF2;@NzZPHbm%kf*{vf`m9}XDBcy2FY zerWp5M3eMa#M597#uWU^y7SK@r{lx=i_Rg8n&3xWUT3fzTV-5C8E3~3Yi9@TA@V!I zFGy B&e6f<7wj5? A~6);Bq`I5KA)te7F@KK~4P7xpK_x$I0${fwfa zu)z>}C0`K{4Tb=nH8LR{nsyNnFai-kVxC&`01_^c{8(sMv}} IbC683Di0P$byr)z{mYjxF&f33ukN=e z5L&MYM<`}JhYRatn)+eK9l-o1y#Ow9O~yO-;wix~l&T&^(iVtnZ)>ZrsTr7hRoQQ8 zXD7-=kL{o@2=oAC>YP$MHxUf`lnapCzZxt0%15{Ov)fB@b3gyOO;ED|3efZWJwL{{ zQV@QlJ#JGT|8&kKm-l#ilI>ky2OgN=`ciun(lWh_OJ6!W!8L@}K8A{kw{l=gB|&`s z%*bIocYo+JzA_032>|Ka=={M;B&njG*i1v6rEyA7&`>C5U9$CXyJTYHz!W|t_`@=N zkcZ`lXW$i!EPe}Olp*kD@)P?LI7v@(-fb{Hb^RxOVk6_Gn;=`#tXdQyJ49T?C0^9( zIj@4k!q7W+ZpkDAR-xZ?HJ?4P5$h9b1i6%{k=9IXx~Lp2ZEYjAw3L*amHklHs`9ja z{|G?| (4&~M9b}`2$aM(-&u55Z zCnju(fc6`HIFOaM_)EvDh?2>Vn=Iwz $VW%lJ16dYs?4y!~mfs44DTFFMLY5*wf zlr7+*rf7@2PJC|fdMJIW7xHxSMUEdotM;Jv3Q%3IveOZYvMFk_nN5}eHgmex4 DNS4Rg&I%xDcJcH89 zp(DFEIIfQUGh@|YX4g~v!~O(>mRTAzZl!-|;eU8{5R>pw9lqacvaC1H18kzj2l3w} zUi=>X`4TU%fRBSSb6*thj|~r_(&7z8)kzUKeq7v+iWIcfl(A2L0hmje!sBCs+_$hV zAP?pW-5DeU5gS$jU5cy;=!K&OZ@PKp&)w(b;)<;M0hA1Ud1XI3MTk(t6sUd9=<4bU zMCoE}XahVPnuClwXeD3aLSEPSA(e*!je}$=M&}|G58NlAkiosH#V4I)@8-s?t>Gxv z14sob6O`ypHz9e2w7PW!z)NN;o=H>IR w 2L}gRTY)fzPvnMysXKBe1_p^;<6K-^uuY#T z?T{w^l{R8kU@b5at+%#-S7EAv?(!K@ZG&Y2pE!zvs6y=m4qkfsXkG5mb1pVEZlbY= zhli07Mv}cg7CBnzN$$}eU=OkV=L>tSsnErX7a=mRBy*ki5)ZG1J{@}M26Ob)IN{9m z(9K* nw+1c5E#kBaU z2d>>t#=2-8#@5Tx8mkWBKt;a9n`GgptINUMp^+)f^s9YOH0d0aG-Z9KB_+9nWGrJv z;u=t_@ELJ5p$TwwbcALNq{xryq@*Osz*TC|_hyDv_|J4No0mKUj@66)Lx?1Ql z&J(B+UcUwm?AKcgT1b^=Y-9w*cFM)W!9j?L_{jN0%=w>FF8C^oi;K}80>%EAF6=(@ zQ<<2gl8#4 43g|gA zGyh~{KNR+sIgyNy<${U}>d7v(k;;h&0*|%f;lbHsKstxjjWhU>Xx`M+RP{2lkap1r zaERKX3xPm^E400})oJDTHJk^~K1xQNKd2pvj -fL{F2 W5F5KxM(I=u=4GKjZBj(v@CC%aE=U)U?hVfEG0SLzwubqOlz&6Z>g zW9``nje|2`3hw|PT8of=-Rli_>DSh#k)@%#(+kHQZ^XgybF(aGwm03YeX^#4oeXWD z+xxcNYuJS26!2WCJY+F39nAx1xdquw*|0-#K9s5y=F0l_oPuWA$jHdteA65Wn$%~{ zhUS)he5lqB@q78~2Uvpvj$&83m4fe{TQ2)cM?6|q&2B%!GHppNP|`(?pp~XTRGpz9 z@Xyeii-~*@D(M)RP-D;_zHZqMsi%_Cmc&K_9i7 48ahI;G=1(G&_=*aU=kkuq`andk&^bF?A#!t>pIY+nxUL+WAVH zi+u*H&fGBcnQ51J3&sARqMzUlfQIs+{fUR@bbE?%N_~pv?y9V)@C7iUKzgs*tsuzu z@Zm!l9U#Wo%d4xaU_uic`-+{enwV^89avaSX?lXwJmb>)T|*8<(D){)vhCg-Qf2&v ztx_;Z=G)TTsO2-X=O;K15^vQ<4^^BAtQM?CmkTXAhBUf~o`uD?;%pcE-eMAV>f8Le z6;enozP8ah*5DuSg0@45;k19RuMr#UIWAR9H0YJs0?-_VXmDS0fwqk~sAHTGSP3JK z)9qvr$wZv_aSfz%pUEM =a22k5uwWS~`UwiG!=xRLw>EJ%*l2(JmK z =B5_*}xS=53)!}A(!3Xj*g7?P;y4PrbIs_aH39##bzxPll1jN<*PJ}Un zREoQDW?FY$@pivwBNyS3MrIO^B-#vgi>N~a&|{*D?YHk3pNEDMe+NY8`qq|D)Z$fW z2@o~n%Tz9Vim+8P{2w +{8;8MRE!?gb15E&j8k$eQ}P!ZTeetx7)T7V;l zDYy!-DimsqkY}}E(#Va4(R@8r$*;0sZvUV_OO1P1*yPw4jwRhbY0FrKz_WxiBrApc zGI@LV`hj`kVcDlE`X#KgpS)47rKc2h<)thQ?MT^z$m$$fJ8+FV%^*I*I&>(y^Lkjh zRJsAGM;2A=W~}GS*{8i$PKbfx FPY+*bm;%r3FO&mYBF!+EDJca@Cn5WlTv}YLsD^Me z%tTWzoDwhY11(_>k=Y0C$XbN ZK71)(Gc** zPvorTVeFxI#yMxW&xDG!kUJ)Vw6iog#xpHV*#a sF6fJ+|_`={i{-8ym+-h;j!1g*+X6h9l7RVo;He45(fK#zWd2#(b7ZMqa)~V-H(# zoS$^Rr{AVWUH2;c(bwAS(0BmBwAvGec))fwMespv-Dm@Ct>ZS4HIQRSjUKT;s|}X1 zdO1}i6Hapgv{NqF7FhR0X!fABL`y+-)IEA9E#d}&@PyIwsk;f#T!p*Ir+3h86#7Lj zHe?UnA`Qk$Z8ypvk3)J{SsC_XBBC!HY;3ZIvo_9Hy6mI5oUhZfBzenY86h&L7wAna z$s+#G$r5>=S_7_h6-2lhZXw(-Lo2 mqsa0VW(#FW70*K9 FzcOPl(f7>WX>zH}p~JVOFYmeZ z>~Pt7#3mCBoMN7meJXDMWNDbpd%q{0n@A0xGj N4wurEGa39%IMT#aruGuF50s%I$|#@WnR|$I@JGPSq~E z<*Xj9>ZQ-A&f@Q(FA|5=kUfh9{VJ-7zciDyY4+I<@+4-fmgIYk7)RcNFu!!$wGc$NL*5Q}oBh{irf%ZS1z6yu>VNSAjg z!P1A;_K2w=W4mRR1`Ja06B-JF@g}d{{w9PXQL O*>M!!Z|`p6! !;U3HeCH~ECzt;q)| zr_Dg&BsRiEm# Vu#+#hc~O zcUJcQDoA@@yzP!rw7!S=dxc u-P`M@D|Nw0!qU zoUx2G$=3lEg>f$PB5T9$`X3Gp5~=k1f5hTd)>VJAj||)6>Fk`EDUOcNWPGpP5?C41 zwmZCb_LJrS%7 al%LnQIMQOL*}Jzi+@N z<#-hwOzb7^2BsdW48DC!h&3!+PHGj7u<6TP0H6V-E1sXBAbI0EAaH+$S3{H%=TJ4% z@}GVppgPE8KXHqYGep3mx4Y^GXK-d@{0UhQ5&8qXhviv^Rw&R;8=KvnoL~@!?x}gu z#fAgJ9%*#EPs0C0=jBSnc=t*^Yj72}ANtU|yo)4e;T_z>3Se%0ti=B6>P^bW=e7|F zH{_1^W37tCBP1He;KLm>X8SIdOK65c*Z~NIkF{OD#fi+Wtf|?KkHMb2Y>1Mt$^-l> zrRmmB#ulj<96Vf&PTnr1m%pppUA=nh+BX`vLtSDo4u)HqnqsCU|J=V!5s6bq0_|sp z9)ls}Timw-QwNtlByT5UFo1_{!8Wk4%6NEkat|BZnaF<#Z}B+c?Q23QE6ch!%pq%e zvr9@KTE2FBQa>t8S~SDDnOPeng-*ntD%Z)&o0Xd}qryOUFg*U0^8{MmzveqUdP)gq zeBXn%jg5`%&Qcdg<>cfb65JJ#Q6r|#f*VpW$2KX!KxXsOLY)Qj;)g00-rn9geXkyQ z^!@>)g0owFE?wEs&0yqQ(&JFH(vf3=2RAyW!Mh_#wd2bdF>&!b6;za%R6j#)R{maG zI&zQDJE{H!eAnn4ys!}d&JF>F8eLe10#A!?0wmz*=vl@gy$P-`CkW?9=OD b_5(cux_5!7%)&w_-+cMMedm5;>1JhhF0(bZ;V0Z~;k7Ibfxs_f zAMuANU^eNo1@stqaoH|2>;1^BdPX=h5*t&VB&zbDltFqzAYk_4Ide9>d2V3= (?HQbt#w8$U#xbaIR#%DYsGpn;Mx;mX=s#=(s&3FEIJFUXei8s^fH& ziO9Vw8P|}!EA>4?sO@%t>0@|$)VU+_R7ig`Q}m}k^Qxerm#1h4U6Hf^EmJ_{)sQPM z)icga6)G=A_u#bTitYIveRCiCJL6+IXZoSRdo_5@OdG0HFOZ+`G6R^z)G-vWjYy-O z!Q!uB@X9`E>Z6RU_)8(Bj7v`gQ@MBUmQ_QKopra#@*qZA3{{v?kg+V`P5~$U*JH}U z7TI8)tzV+S8$m^&uS5xfk}N^o74CCnHx&aiLgPb_MTQO*&f@eB_b<zqHB(qxI<0>r;@u*Bg9oe<92Z$Ji0dY(fIKgK!24EeU6 z-uhTd`3l4O>(|}DjCy M`nArAkVQ+V#%%O}>uukn|p?-YO7vwXh2%;B!-xPa7** zd!ZP {yb@JCWTgolD`kZi#O+j?a#W)p1FmHjCEFdn;?_R8$@ zN!x`0GWc4tp?}uL0MP+wwqBNVfG=b|;Xd-MQfwLBO@;p!==`Ak0!IIS1Qr~%;6;zM zna0!Er<8$}WA=j@lrp9TOG2u;RH7InC+REoUc2O6n}1N#EG8r*&GfF(du^tg?K$c~ zQj!s|f1RL{3p8+dr@igJR(rwgGfVc}PXEBws+nb8Fd`gB6(4_kR27ML?Ck1lVsf*` zb|LNj?X)P*H`W5}y=~@&U2|>{QcO77!_MP_!x;h$@Y>E;qx-d_+&b+0IR(Gc9v|r( zwvZ}5^XvIKb6L;G 3P=z%c>0&| z`e>f_L}~cExuYXFycQBc9B<9Ts35Vo-;B**Z<@%5Vh-X;G ;$ zNF$ qt+n23$HQ!N zXb5gz&Q{O(RB;D|^K!Pc1an4XpWTSj#Y3X%h*>^Ldv (QBCwARS1 zGDb$hP$?!!2s}|%=>mZ$py7iJu1sm8hUV$D-vfYx*F6wgT9J^SBK&GP0#3gp>jkUF z95^+87m2&5S>p49qVcHQ(Zw*!StL0-mguRZqp!qcW0{tKX8DvTh>DSGMH!Au_}7r4 zLR+hrc?l e0@qP3tkbRdtHO;_|^qxhFse&%)X@g_a9mRqexS|@t;fP zHvV7v0ps}9%|rXQ`TVLLklgoI<#wV>ch4qi!~cX&`39~?yzY~Y3B|jOt|a{DW-^|) z|GxkKFPrLr{{xrzGrvZV #&O3$8!nvJZV5)jaC`F?VeL33^}d?u7aFvOf!c)cr^ZoFVG`mG(`t((e29QF5+Z zZr{@BHO4 Zx``5SL(|R1RO6nytNN%N`8=o zFLKjNNrewloVd46D0B@MBl$2%E>Zse{QJ!m^7=nq|3CQ1zBdt0viAFe_u_!t(uK9& z$ 6Uw|tNSebh-pmeN1q~#Ra}dBq(1z>FRoL}LErz(5B_0{?^Vrg z^;*l@<2ki>sEgG^`)A);Nf}GfJLN_Cv* p}noGTW84wg?He3R3Z>J)wjIXchX@NW{5xE zf-m&C4`!JP<=E7=drvYZcupK3yZu=%TW!Cd*L;d!`_t!dOU crOE!HJ}N9ogRL*#mTd5Qk)L%jvw3jFHAl;1<=t?m zrT|)#_z!t}ag9IJheHC`iMWPpX5;FH%2Lmp7cUM>md Lf{?k9Cg(CE43>sI8$X^t79ur>Bj9(1oN+Cm#wxo0xH_-aNRbr{J4zZ~J{wCh2^g z t`+GteAnZcYTYSo6>z^{P z5?R{xm4c#nmqB{JsxrRr{>41)kTb6V9O~(Zdc7ydtJ$GTP_Y{t8^JU>Im!W*hWB#b z9{H3} I{J4>=N#i>LuA#8umIflV3QSXTJ?-n||iAkN- z*GhC!aufuju9t{2qyr_%&hlT7Bwoq?**Rz+Mj1oMyQ`~}kyqQ`j4cT|c>kJ#(Yb(v z`MdLXZPT0i-DG8&XY1=(WLpd7A7^HwxjM@K@#;&l(%!H0FENbi36xwc0Y23@=;TQ8 zcJ0se5Geeoev5y_-Mh9#s=C}Rc7?O$tp+ROq0X*SR0QCop8CvK^qIJIA#QyP_jH%v zDXZ3=tshqVy|i=#LObH);*FxmTd5<>fCPQfd2-nM+BGkam7j| *kQ5Lr;ivg28Sk&jPH()2CYxI& zF5~gQ&ddJE;ft#?#}gi&ZFvmZcWmTX?0NS-QLbRG#VQM>;2EKq*QKTCAttXG+$ JIW_E@3>MB85@q(q#K$as4`=kv^vPnCj!ipMjZ^|x zer9b=o(If1rdK7#$K%CHC8O#Bq%#X`@2079ux2(~$pdc~AA47(t*yO+EbHtvXYF)X znz+0~yOFxTBZ~vR;&nClS~;~HjFCb@9uJJ7<3!Qq=llC393CA@XrlOa?;YlOomVxF zs<9xMuB)qLKHpB9Yd1%URPzJHtZT*H?w$S+VDM{{q#R(^Us(36l@b4H0P2b2t`yvy zAH_~oc-5U{o_8Q#<$}uH>`7~4s+F6u$Jot W5(rEc z1@kIdDzFdP_P&B4F+;1^b%6bx!_qSC!t=et(rOqyV!tTR(MZQNyvg d@ 3KnR*5n8|T_eUg%`X7oDuwZiOo^i}lHI@jRLzh8-1?R6m$byR!x$35hkT z3mVB4uRXW7Y8x2nPPyvpc8xQnS6wL`T%nN+JsN%l;clkl@hUxi3ev<=C2lQOlwJ5) z=r$gS5F!{Fd5KU~E(OXpcdk>XlvuVGIbnKRe{bDnqUk)j(tY0Z;|3`vPtOe=Zwx)g zHa)f1C9{tgU2jyIy}S0YGiF%Ta&GPJ7Tn#t@vYPm`1Z-M-3cPGX-5rSzc|?*YlcLs z-c 3hRm1lcNIT0`JO&E#k4Jpbniu$DJwGu4shuWFeFbOBbH`>|WHg=abaeyI z3pAP^A38(UiEo&AZ+861kvIBdF`H6`b+bL~qBg8F8T>Ij!P!08g8L|DB5=Vl?p<&j zBZb!J8ZaIe_}F^<@a ;G9{e4S1$elAbTY$?#*Q>jI 69^2o+cwMMkC_oYT}Z()RiSmsU6tKVj!J zTC(+Yn0fdvY0JFA6Z#1~gNHtm^)oc62+}_HY6c RJi@k5X%dM9 z?*X`$&i|=S2Ion)*ZEl~YuiI>WRrMSH=GUMz8>DtmGC`RyKfq-=fuQtTr7L`r4LAQ zNKeN_Jt}wlc%z)-Okik$C^+lG$7W^(%ix?2jIP=Li!xm)v6N5V>!+_T!3*8Olh%<0 z_qEh4Gllz(B|7{Xy7s~^vOmDhEtdi}H^-@GPxF `2lZ4^Dv-d zri%Hy&*$&j?30dA#7`huQb?yf%hJhubU>wrJw!Umnzu;sbYX6;p1~}5DRf?pEB!O9m) zp4Fafz0!G}y|EHjnp4+0gvE*d8OJYdAiVCaLLs){N6^RR#igspIlqURwl6cg=y5fg z=Ds(b))U|${$6N3;&TSO?j7?vaW4I`cCo{9H){v1>VD)lH-{gQoNPO5Z8teKy2tUv zLx#ZX;!fAo-2g<9i!TdA*=PjTnr?o+iGAlceEg8h=RuLTP6% 2w~AR_ zQ8K0T`!ySL-8?Tf+uQz{(Hp(XeFYqAI}G;4*q7*{&YOB>r575Im<`IjfM1Titfs># zG3H+>@QY6)u6fv8dwx1~OO(Ln4xKY3<^pF&X9{Iueyhhy)3Q6gfgcm<7>dIg;!II@ z_t D$uJ!OQmE4u*3PK m#?szY{(K(~fvZAsbDIENT#ioc zs?DttywhINff{Cif;LdDtxbAuN1w_#?i?VwU%C=f+0UXxDy)_}>b2-96xQ(th$onP zi3CbYEok oq9<{Xlt*;@ptu)RV*$eT3Oox`@QA082&c>D>MA=hYnSP&)3x* zR66RJl9v})Hy|9bmxxO+CFW-HSTnPZmF4H_+X-Tl`jJ}IQe9mj5rfv|aWf)|N=h#9 zMohGXWYkVU`s3)jX;Yx1*jeru%%QT*I$yWq1I@orYWw9U-Sq3dTBMnUMOtoWeSsNb zob;lV4sOS#dP6zkQ376Sp?3oB0X;|N&wL_4z}xSPQ<}O_Zz+Wk=yY~w96jn$;1uSi zzyzN6;~N^Zip)7J)&(z}9Z%}$@oh1;NRyM#o|t}wYs`4`4&;Z?|JI}|*_X=+ZTpoy zJ3rrLO^IqQ{Wdc(+jIkYgL5MXFhY#|&iMG8D@$e2U}{m*W6I^3`eBFwLgcRI6>8Z~ zZkEG6t*N ;vP52ZIYubtym*LfJ}BxDYhR1SY0-C9zAp+n)I 5 ziA~!~J-tYc;GcmhQ4wVDUxxgN{E8jaAlHSsPSc;$E?c%-FOeBSoMLCEReD{DdPdim zQ~Sf_Yz7atJYD~peDKArHkM$2bbmcnY!$}&tL1yvrY4$RptWE8x>`7?y<^kOMMZ}v z8+(WI2Ja {3K}qr`qD;@w1X{hcoF9ie9bl*m{2nGAW{MUGmWA~7); zW|PLe`8ioU2Z-tdnk_r|;fqbnP|v}3v+a2;dFpuSH-k5Ww1)UTuj ue4@+w|U6 zquHJZp*T9aUAmkfS<~Hj`qboF^X6Zh`;eMiwGn~1U)pSQzC2Le5n7Y2ky)fiv87ax z>d!7lCWk2`-IM=djJ{C=S7BVo?w?5fiZ)uzb>x|1Di ;lYID*P#Eyn3 z?EGo5@O!Twh~=CP%F*k45-Y1P?d>wOm`qqvU;cJ_^+%{(T!zYn5eXp}R+im!l(p zF|L&-GB6}|FW<%W?UakcCC)~V#m*4+mgn+y3~zhAtCq_7_MvWT#~RPQUtB|J-{SJ) zC&I4+bH~hoxQ94(>rsBH1gcFEET0 u=k$Ks}n|# z(4}ie98#)uCtsf&Iit`$+TX4E%BUxN_5B^%E3*ksQiKJqF3-7L=K|eDbFx3g$=&EM zIu;wh>B`l$-YEDQ1Os=iJjR*GR-Z)pPwqI~e~g;{KR;lQ`HIlns2Cf@_=a5$KwWN@ zTlb%Nr!Eke+ZB&nKjOb5Z*!btA&{4zy$ipb{^yHp+(N^_jglS!i~a=v87!L?XgJLt zu|;}l{$(b4sj&BV=YC5D)EmLF>zwaD8xw9=pu_2}vU|_+R6g(i_PX2{$LZCD-W8Yx z_efn||0@^c2OiP;AMPwrIu}cb?)1-g+;G8Z?+yU`arKg%KsCE4~fGb=RJ7x en{J-8U;DGqL8##y>z6SCUgu<0WS&bK`u;C9E;I-L diff --git a/images/tutorial/02.png b/images/tutorial/02.png deleted file mode 100644 index 0aaf7954cc1d9b3a8427c9d5273ba2c7e5e2a2c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32750 zcmdqJc{r5s|30imDxyu2C25glltOmOzLqu2pt58a#@K~-RI+c`Rg8pDc4JE_%wS}j z7)!DYrm>B&FTd+ny}!rt{XC!F^XGFMPk&^(U2|XC>v}Ecd0suct)qJA;HiTQ3=D_V z)s*xY81_suFzgE4zYjd=w-^-%KbSn!wUwDBz$3}S2S-R7;L$D@Hywjrgx9DP28O@{ zbtOduuYT&LciK7S^p)-U5-d7O@?>Zbr^0PkMd#xt?21NrKQe#WgE)>*M3}Gz{G7q9 z^=%%wcsYOaT~=`A&njNN;Mb2Ihwm54b=$uO_PW{skUL#+5WzkykIk35Cd4nwAB8K( zAjUOCBaCg7tPG6mXgtZB1$V2;?8VWHU_1;A9>c*wXYxc&Mw|9A?%H{B^D!&*Q-S?( z)*k4??Ol7p)W9DDhFwxSAJ`TCG-=s=Mu7=>lw}ok@!yZnS$;PgKO6G 0oa6h` z5FT{#&Y$ ilER-)3WnNRT2Gr>uPoNp$>kB>ocZ-Nf{S#k$1IpHIw&Y;=|N8Xi>N3~3yaL_ zsoI>}+>UGxF5;=L<2&0c#G+bS)J@)7Do(tbQxAUZ{(FGz-4Cyi>1VFRciW5EiKiAj z4@#|eWp16TU-=?(ZvXDQ9{YtB-2grNvZ|mF4Z-A;lvsCytg(uUO160so@;1sU!P0; zrq#oTYT(frs(w8kj_ffMy?XVGD)*y*9IM6?7aC7*z2|u>VqSAsZfiaY-)EaCeC*h< zg%45wTA1n?sVWMl`g!5|pO cri*$O+Gl7hYAt51dGgV~V*(e!)>Dii+ zn$ gzUFkVU@fEQb!YD03i=d(WnbIam=9 zDE1>UNJ?)Dr)}C9e*RfMvh-QnHAK2>0CL6I@~Jd5psa;>z2Y7tvz`5_TEGb@D_Id_ zm*e-a-cYDpNGMW!^X+L(V9z_l;x8x6nsEt2q)cF;G5K{x=C!8fgPtU{=sNIsnM+%* zaA8i|pUvp4Tb~hv*21y8&t{=GsaA{E>&LFD*}8SB`JZ1FW>EEJ&e*97eZI4`XEUod zmq_DV>$6E=rvvWlNKKuqTAR+d9>gMx+r&xD=DyVP#_&f^o<1D_Tje$uql%LrnrE%r z-deYY-u2G7RwH1OVxd{m7hhLbhw1*1kT3At5g6zc8pV}45OLt;B5gMM8GZ}ulCw3m zu~%WdM`My{xUjG=>0tLGF-)iZZh!sybxg=&^?41LbbJV_l$w26ik6mE^C~%WSkBnr z%j>QjEt$~lJG^e*XPtyl5 CzL&+TTqea!Gb$(Ug_}d=*$-#0to^f+e+HYs}ayvG7 zRup!S*on(J_8$(C T^(@jvz{nBhH b5t2F1^wyx;Q#xO|D(x8lf!>kLn`O*czFID!=G`pEA0EdDhF@> zjb~H;1}hJ%-$VbOf$sg^D?YXR@2qqG_Hp{Z_VMq^d$2tE9S(P&{P`#JMgQ-mJ@8+n z>3`qle*^CSZ<<&$|6SHh{y&+4-+$~1!+%}zgM5Ed3$eepP|o+)mi+#j^}>Ic^{)Tu zV)y^v_ J4` zhHwABZsb3m!GEsW|7BzL@4oYINAYTW|DOKCe|$o`j^N) tDb5$2BX6{cZfO9~1wNH{1VxGss>4*wE^~z5d|-?aJN-{f8U* z`?Y@u^lu-32lPMQ 6Qoy_1cPg|SE-WmL>cKhfZE<#EVvC<&O57M+|t z%obYBc{!DJ-*NBT&_jlIS)!ba*{yc{f*W5#M!@z7_VidL(+`1PS?cR2v-sTD&%%V5 zZ<^1W+8iN|e9cX7?Bbtf-TfW+WeqA(Iw;r-Mbv@ajSVJx9qa+JyaneOmko`+?hno$ z8CPbLMp2KAEMPG%P+xDc7~2j+@I 0NTBy8euzVV2m+0CqV5w zI?GsmWV|jFenQ|%u{K+XkeQlkvYzgs 9e&MU(-Vr-d>j%1n#V65{k30VS<~4u0!t1-x zuu8gMxZp*)6ElKTd0BcY(Z1X#rT3Lk*IIYa8N1Eu%lPxDvp&0|i22QA|1|~k@nT^i zx!0d^SiHO0!H~WiF!JjWe9?A$x?YE#4$mfVV{Ld5DlC=zp2%fBDh$We%u9vJKfsQs z3tpp~T4BH1Ejttz+nb l|qVwi? zdRYMyvVtEMDp;_T$SQ(^Nw7Qdau%gwZ`XR yduLXuGW7*|~C~h1fqz)A^Piq 9awY)cP5IR5_svqieR_k9&Fkjk8q6Kn^0V|F$vj@R_;ty4Xc;&;x^y zhTFjoM5u^>wQSeWQB97wWx=(*56q&;Dajs^O~f3GI@RV~V5g{7lvB_f=l#`rL+Z$R zZ(+f+!={1rd4g{vi4t-aNg-_Nn*=(8UJZQS(?fFq$h!ag^h@`-sFn`bNNc6(QX~CE zP-jZ)v5G`>978T=fU}v=jobT~P=&?)xCtqX%#Z^x&N=?#D6Ccm_2U!aW54?4(AXwY zexIW$`E+0=Mn;ZF!jb88@r!@d4sMM`Ar-3#4VeYw#cfTt9?$ZMTxX%#@TIqME|w-Y zxz0KI7DZ*Rdr^WV2aDrEo2udG&5Sd%&x~N*#{A#kTs8#>uW4cLykGx#-&SOi3&QN5 zJ C74k^c*ucx*}T9ZOG1Q@l;n_x~SwzNP-ZCW^y zppKH{keRYnc90SiI}hr^;^Bbx7Es1qBKQu`cB;0A-}pICo{aXX9SR_TYVXC17tK(G zw>*f-9PlQmi{7|#adUI?>&P#(!a%v46aAn(@!@rTLPh`84c@MVg}BhH>I^2{z=3I( za<{6C&MdQ8jl4)7CdI>&UiYhaN*9J|__awi5xLawAR@W7GI~uIHTpF|R89`YCE*+& z6LZA-=Q9B)*N>H+sk{XWUQqWopL|*&S_G@$y7ukR8#V{=RJTtD1e%?zsfpit2M-g{ zFs5Z1b@LG}9^EZQ7DVgG;MQI#5~w@IpPr43P6BnI+sa57{>Kl?riMMFjRZZYbT&5k z&Kd9;jd1(IZrp6KwZH+2yV;9HFtw;{s&C|S1Jmitsys-8F*+Vry;DO!knUU4Gf$=+ z)0Mg7Iq;~dc_Cmf &l8PtYMK9sN^@e@0sx!3SDX^ zo~pgSKi>PU@1>6HF`|}**Grn&(87t;HDY}RMaOpqzT`VorHZMVI0XuNv#}HhOw81j z9VjGI^SyrA_m8-}gxy3tDKz%9f(b}U3d(P<62=pX{H&dkt^IQMAS?kYYoD>WurcA# z_kN&P`$QYMy1LwceQ3fxYT^+P@$JL&<6GZ-d)N$0p$o%ny%l5c@-eYNLAxkE$l6$} zU2TTf&u0sHy8f3IhQKTg+a<}|qs8z<@-5<5V+;SXU-jEtG2{MYXHUmKW$81g7z39h zF`&lP3D{mARvlVj8I8h&(yu2*5rsGbCjFRmmyBA^E$G`(!uj=1TXE{BRrgUrqpf zQ^9jFkm_I&m$@+};5Hf|Q68ooCeI bXF~okhSpYm!hah{c>I~!jEYRN}{= 3v?a87!I9CD`kJ<{*f{%QOSwW?L|IgB@KTRfess9FuNVY)}1 z=5YPg!cUI+yUum1Hj>K&?$#uy3T>9TrmtVEHrMFIo>Wtl5}SwuM72?Gp_u@exMOq# zm&AqMGNb&KAST|6N#;Izezer#cxX+{if)*>@`}8n($9G>h85s-u=~yR-p@(}4x)m7 zaD%bG*Ysz_>Z*us1eh^g0T$8N*r QY6$aK)f!D)c}_Rn^+Z$MjT0 z=w3xWfWxh=uUogoo-g)Wb@T4YH$U& &){$P95uRJqS=ZxFUGbC`R* z0uWOUcD8c%3F&!j28k72Y5oXdY@4Jn WifNm?qMsSp3uJl6CY|$6 zECbM}S<`$Wwpr#Ujb$5=-;-Tbq#GI %Y#b@_J|2{A4eRRt`Ubgx}Dz zy5K4|@$$h~S&@ICA2`nOnj*CZ({${-{SWk7*~`#Xk-P5|BIUQd$b7^khubIQY_W#z zJ^9A?yxa`?4L=#zl8D3mb-5x!{pQ0h;+Jb@{rkOjwY6K`8Rm~!Y>iutfz>JJjXn^p z0B|uSC9Ad+v4xIXY%SD^Vc7Qk>FqNRI_AIBr>Y}Oz25gAO16|~cOndN$zr{qIQG7g zHIB{i#~vMO@=~wod>tmWXo_B-Wip@riZas0p+%!{4!JOYI?xU>r^i;w*Cyo}Nl1c# zKSmjKwcM!O)6Go}va%@IrI!W3L+-e%j~^d9f{j{;MAh6HTCQ7e0brNZ?tcJgqg6*s zpKa!-Us0B%ck7=ex1rlAA1%-ZlN{b09Bu&s zo<5Ty_8t{LH9K!e9a_9VU7rP3AvseyI>r{ba!b|Q$@m7b%>kM{K=3pmNU|?rojM+` zuM;CtGjd4u`t|OsvlX>er(#;WG#;44JtzI7^;gxbyF^5NXZZ25v)WAYAaJNV-Fy!v zf0H?KOoQh`hGH)~aKx@5g-Y_P`B5vY;4tAbw-yB?Hy69C!8GGgHKRNW-TJ!7%hlkB zsmib2S^X+ecjUmwpA9eV-oDq&x`!=B>V-<#WvCnPw*bhFA8=$zjLavLZ~ pvR@ zR`H7)wT$RFknel{{>?40(^o%emsoXZQ)4-X!z+?m_f@^v=x8HV=#*P#>>TC}{a8_j zWl5$%a=CRO5!u*^Z1B>1EWi&%M#$A$&( SxwwW zHu3rvK#<;7sei~W2Ti*o8CrxMRxtLVv7hCO@LQzbYN~YFXL0`52RU@1bmiUK_bfhl z`&qDpPfKbolJ2=|a-BY%r0YMe@78=Kta-X8UwYzE>(!=aTR9r1-Styc%L{%pHNutZ zxVI!+&p{b&`uiSy_YhcA-*G1wU$ZfVrZqZc{QH9_CyNF-JO2xe-i7sGBC3+K)@#xe zl2THa!gzh&4!AZR>UT?3QTg6~X`{+3wG95~3;V(+j=I}3H8h>o+`u(~)t;gNYq$WG zp6szp@))9iiPP@hs5JkV>&NBmGLX5EpGAJzn1&Xzl<#5x4Gz?P5}h1bUcF*{8xbr? z{5Z2*zg^4iH`lNLQY4Sd+j~y0Jm&Tui=j3Z=9iFy7guQIuYRGW`hqvJ3*7v$07b=_ z*ru~f61{+P+}fJmHs=jkx%Z$-3*Uk0lY>u$i`3mcA#8elb)&mG9-Il6Xv2IXPF>ZG zghu3GXb3HF&OP6_D6x@E9uA$elWYekrwgQKrKScv%mSN*%p95D#ZAX~oJW~)Ww&^d zSPjPdE`SaA=%??sPu8itRlHPo1J!v2H7KKG({3iWG1o{+UYw(l)7&FpL`I(4!;W~L zjn|sSJveCObgRNCGs>*=VT|8$^>6~jdAMr`?l^fU%_yM8U%zaEB9#GuWt6eKK}Pva zfH2r*1{h~jzcBhnDW@GFY7^jB#Rof?pDw< z(WXJgZHJ)#zCp5o9ftz5?kJ&(^=wqVyqrJCTA%>0-xNKYAY_b#vbd$yN%iH&y^K@k zAHNFRMYVhkvycbp(2AUnii}_V(8L=TxJeAucFbL=(V{!Gp&Ei2-Ee}r9yMf;93=aJ zQ0^(!Y$^TY{u)Q!p)^f|R*^u@H5qyWv2f8NlgQX-=NyK9aH`+MEL79PgGmMqjX}v8 zm$hCaLeTP|%w7lZZx`S?^aEBJPpf1&q&M=^Re0)U@e;(RcdI8YU1hwYZlazOU$aSd zNdm^P8M7P}5zzvUk?`o~=vU8g2__Y3D+@!b?I;|8 Bg170FdSIz_ksRGI{{jp}} z^&tV%KKjbje^z`%pPhDWIeL2{Sxn~?e@3^w2i;w;1kc0l7req> fjaNsfCi_6RSsoYH7s(01Ghp!%TCzd5SRr=^n3@&=* zrhCS^w@c=Z@)EZfb+^wg5BU~-*ed@q;Nc*a6Ay7bEdrSXcfPv#jdDTp$_{UrUx5!| z*MC1LwILtSp`^-@W-D1~@Y!&n#7{0y#t!n# g$tBaSS;?G5r_%?-bl5xSg{6_-iOxv87>7 6Sc5ztG$wt NXd3;!(OgQ#OXvtD8Pt0ml} zt~{(oW;UQS3tX=Vj04Pu$U;AGP2;k&-ci?29h!ah6K(y3;<+}J3rxKj4~ql5&&Tge z<>X>lS?!NYCWC9uZ(b-5Gs(6sIQ7kxCeK{|4N%_6YgZKE|K2VoPdxJto#k(7sW4N! zJQUeFMKNEN0`9JVtvZ7_&3Jt>RIq-FUX&E4BPF;{2Lg=uI%yc@K*S(1^@5e1)^Zd& z7RtVPW@{%>9Qw%_O-Y6Zt{AgKUC-Wl<#vZ`wIiVZ IU7h1(!g9&UiYQ6;GgI!~wjFWP8aot&tNwS%NpJW= ze4?W6sk#{-HuBsso7{?J6OD<-Bl=Mlj8NUHPc+B!Ef#IB6^)Oc-u4=!0p3;T#>igp zjrpdtw2ng#Y}-6_!l@3un}FC1P+i@g-QFTh-L#Z5Rzn^5geV@wT4M|HYuXhF(jm^+ zxl{_-BTAdLBv@UD`5m+*o}`ClG_h6+?^l=NkZJS&kYo|y-Q<@jY}VVZA%Nh65#0Yl z&T^hQ70V2(R5)?@?qP50`tWug6fb*)N=W^LL~B-g`72lWK;6$XTMf=e1pd8jd70{l zOJ*L`V&z8|o^iW4BP0X04?#?< _SFLa?6u%qHywCqw*IrSS^|Roo;0` z64+Q-aUVY_sP?%4da|1?UUo~5RJp}mKU_W(TfAs8t-mNaSvMTwRqJSs(RDYg$R@Y! z@Krvt?yNrjtE=$M%}D8)NiToEzO}=*OX;k-@(VFh&A>PFcn?obzO4c1LHx64&pfN_ z%S?YtF3*1)Fv@i`V*}Lerzgy3i`dxM$VRsM47`D`yh0C}Sh{}Ex>ikXF{?Y^Rg+0y zjoPmhnsj}C!^ByBPllLeqw;mhSLHKb^}c!YCZ?(IMVT=ErLtbf+xS?JJ{kLRRc FVy|~t1uv|^RFTzvYJDg?(cJS)Ty!D#C<+T2qFsAA>vkZfomFv!KZlZ3=3v PIRo+aT6V zh`9mY+oMK8({uO&fyJluiXVxF4!JtN=cI{h*mu@X*_^cyuIy-Ev)kjy_G2^DkgKV; zO$QQe^O)REb?RYfw!v kleM@I(-G72HEdA(+Dt4hOMNWd77 zGV)iijvRlSlE{hK26&Ry=U@?11LL5tBS#B!y#QWv%-bqFu1?;rT`_9-K7lSBi~jc< zW`@j2#eYWzjN}o>a2y1dmZW289tapF;ks6p*dSC70a1vDDuN)l?pQU2PUWYb+Lj(t zv2#J4*wzASlVx&jqDe1e7o3gTaO% &IQa^ckscsl1{@cE_)j zd(&p~K3MMeon^_Gbf;M;f&9_5&b=rDJDMABZ}H=@U7b3IJidA({DkmxE7Qz?HM>#2 zPsnhlE9FAjP1~&U0Ti?0O9E-GH^*woHYI|mSDRI>=+iy@S?~%OgtWEfvc&$~-XPNF z^f{{Fs)2suM&MAn91ws&Kp>aZ+P>xo4<5LE+{NS(-y`1LpMx-^a>xq|_M-eJGE3oI zqGtXV;V{x mC?`1! zm6-}3eOkKPqsz~<;#+5eNM7wv69SNsNP;?!pL%3(5ZvRN@&ZV+m899a6vJi0*ed{_ zQ9|(CYX>l<^e!p2gPmH9MaVw0q^35Tu9I1$wWt}~%m_=F?&A#P_sYWU&+9Jvb?EYl z;hDDUp9imVn{#9dq#wOUN!>dI7EvKr&$(pcRMxur1`gR*XeZMQiH`ae)hPbDGV@C7 zG7d=!XL( 0|mx7P_-li)ZnrnTJ^NQ0Ti=Fl}EIqMmx>Zr3X|%!V49yAZAdB z$O?1!16pyI;LOq??I2&T&6M3nMA|a4Xm#)8yyx;EErfH?i3}H+5MH_FWA$#;u#gOo ztcLR;&X>4$vM9dfWy684feg*ugn8y|_sxPS92rZDb4HyA*qm!*O9GY_q4EZSKEx%i z78qFX-oeFoG>-LXY5&h*VDIDXMPAC;vY}weAh3vMJit`ntfR1xAwGxXKYMn7lZS_T z*G0s;Me_w-%@hrD$4H-im7hyp3^RJ%zs4b_JA`WX+u#qFU#sr^Hhyp$J6<(}5iuuv zRe5acRb)7EWYbpLvA<)j61z{T*b#Sei7f)XWW@VrFmI-r%9A9>)789zLl`>!gbg;l zt*`hKcIr$RH~;C4A57B4%R4|2o6Ou} XbP&1O?mQtT~aWZ-Cv&1vKQ@e7Rd>WbbD*n5rp zRsn>0(cj-cX52h*lfD27<(`u(-$NtG$>}Tv2T;C>JAUu@FylFhG|r`yMF(od#14%T zWjX3kC^+