From 62b00507e53d5feee4eed748d41fb9407d62ba48 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:28:33 +0900 Subject: [PATCH 01/31] fix: unblock signals during continuous keypress --- helpers/ui.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/helpers/ui.py b/helpers/ui.py index 0a21890..5ff0fb4 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -669,11 +669,12 @@ def suppress_signal( ) -> None: func = getattr(owner, func_name) if state: - logger.debug(f"Unblocking {func_name} for {widget}") + logger.debug(f"Blocking {func_name} for {widget}") widget.handler_block_by_func(func) else: - logger.debug(f"Blocking {func_name} for {widget}") + logger.debug(f"Unblocking {func_name} for {widget}") widget.handler_unblock_by_func(func) + App.treeview.sel_blocked = state def pluralize(plural: str, count: int) -> str: @@ -1650,6 +1651,7 @@ class TreeView(Gtk.TreeView): self.view = WindowContext.MAIN_MENU self.page = WindowContext.MAIN_MENU self.subpage = None + self.sel_blocked = False self.set_fixed_height_mode(True) @@ -2038,12 +2040,13 @@ class TreeView(Gtk.TreeView): return False else: if is_navkey(event.keyval): - suppress_signal( - App.treeview, - App.treeview.selected_row, - "_on_tree_selection_changed", - True, - ) + if self.sel_blocked is False: + suppress_signal( + App.treeview, + App.treeview.selected_row, + "_on_tree_selection_changed", + True, + ) if keyname.isnumeric() and int(keyname) > 0: digit = int(keyname) - 1 grid.right_panel.filters_vbox.toggle_check(digit) @@ -2078,12 +2081,13 @@ class TreeView(Gtk.TreeView): Suppresses spamming on keydown """ if is_navkey(event.keyval): - suppress_signal( - App.treeview, - App.treeview.selected_row, - "_on_tree_selection_changed", - False, - ) + if self.sel_blocked is True: + suppress_signal( + App.treeview, + App.treeview.selected_row, + "_on_tree_selection_changed", + False, + ) selection = self.get_selection() self._on_tree_selection_changed(selection) From 5a33591d6ad5c1f1fc7ca9e4a0f4d8887015ced4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:29:03 +0900 Subject: [PATCH 02/31] chore: drop extraneous path expansion --- helpers/ui.py | 1 - 1 file changed, 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index 5ff0fb4..733608e 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -20,7 +20,6 @@ from concurrent.futures import ThreadPoolExecutor from collections.abc import Callable from typing import Literal, Self, Any -sys.path.append("servers") import servers as Servers # noqa E402 locale.setlocale(locale.LC_ALL, "") From c5b13ff0f3b6fab347cb2015e6c48efbf5c54bfd Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:29:22 +0900 Subject: [PATCH 03/31] fix: strip newlines in history file --- helpers/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index 733608e..335f9d8 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -780,7 +780,7 @@ def set_surrounding_margins(widget: Gtk.Widget, margin: int) -> None: def query_history() -> list | None: try: with open(history_file, "r") as f: - rows = [row for row in f] + rows = [row.rstrip("\n") for row in f] except OSError: rows = None finally: From 6b31bb3ab3f40b8e64d56d5cf24bdd2718716995 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:30:02 +0900 Subject: [PATCH 04/31] fix: resolution shrinking by default --- helpers/ui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helpers/ui.py b/helpers/ui.py index 335f9d8..a6afa08 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -1107,6 +1107,8 @@ class OuterWindow(Gtk.Window): w, h = res["width"], res["height"] logger.info(f"Restoring window size to {w},{h}") self.set_default_size(w, h) + else: + self.set_default_size(1400, 800) def _on_delete_event( self, window: "OuterWindow", event: Gdk.EventKey From 1bfb054be2dc2d9675334e95229d25a51b6715f9 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:31:54 +0900 Subject: [PATCH 05/31] fix: ease padding on mod table --- helpers/ui.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index a6afa08..0eb88ad 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -3155,7 +3155,13 @@ class ModDialog(GenericDialog): column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.view.append_column(column) column.set_sort_column_id(i) - column.set_fixed_width(350) + match column_title: + case "Mod": + column.set_fixed_width(350) + case "ID": + column.set_fixed_width(200) + case _: + pass dialogBox.pack_end(self.scrollable, True, True, 0) wait_dialog = GenericDialog("Fetching modlist", Popup.WAIT) From 95a775cd74446abc7d0c0ab1430c5df89275749b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:32:14 +0900 Subject: [PATCH 06/31] fix: newline in tooltip --- helpers/ui.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/helpers/ui.py b/helpers/ui.py index 0eb88ad..a9d2349 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -4276,9 +4276,8 @@ class ModSelectionPanel(Gtk.Box): }, { "label": "Highlight stale", - "tooltip": """Shows locally-installed mods - which are not used by any server - in your Saved Servers""", + "tooltip": "Shows locally-installed mods which are not\n" + "used by any server in your Saved Servers", }, ] From 479be05452ee1f4c1db0ef85dd056398e2cdec51 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:32:33 +0900 Subject: [PATCH 07/31] chore: clarify comments --- helpers/ui.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/helpers/ui.py b/helpers/ui.py index a9d2349..a5e06d1 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -907,6 +907,9 @@ def process_shell_return_code( def call_on_thread( state: bool, subproc: str, msg: str, args: str, choice: RowType = None ) -> None: + """ + Exclusively used for threaded subprocesses + """ def _background(subproc: str, args: str, dialog): def _load() -> None: wait_dialog.destroy() @@ -4022,8 +4025,8 @@ class Notebook(Gtk.Notebook): if hasattr(page, "steam_entry"): """ Gtk.Notebook focuses the first input field when changing pages; - this workaround unhighlights the selected region and makes entry fields - unfocusable prior to the page 'switch-page' signal, + this workaround unhighlights the selected region and makes entry + fields unfocusable prior to the page 'switch-page' signal, then makes them focusable again """ entries = page.steam_entry, page.bm_entry From 6d428ebb0da3382d2831977023515f03f3666058 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:34:05 +0900 Subject: [PATCH 08/31] fix: ease padding on mod table --- helpers/ui.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/helpers/ui.py b/helpers/ui.py index a5e06d1..c81452f 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -2562,6 +2562,10 @@ class TreeView(Gtk.TreeView): column.set_cell_data_func( renderer, self._format_float, func_data=None ) + if column_title == "Mod": + column.set_fixed_width(500) + else: + column.set_fixed_width(150) else: # WindowContext.TABLE_LOG uses undecorated columns column = Gtk.TreeViewColumn(column_title, renderer, text=i) From c0b5c5bd89607f3713bb61439c73edca5507fe19 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:34:58 +0900 Subject: [PATCH 09/31] feat: parse hyperlinks --- helpers/ui.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/helpers/ui.py b/helpers/ui.py index c81452f..02022a6 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -3133,11 +3133,22 @@ class DetailsDialog(GenericDialog): for row in response.data: self.store.append(row + [Pango.Weight.BOLD]) self.view.set_model(self.store) - self.description.set_text(response.description) + + text = response.description + 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) + + self.description.set_markup(text) self.success = response.success GLib.idle_add(self._load) +def comp(string): + return f'{string}' + + class ModDialog(GenericDialog): def __init__(self, record: str): msg = "Enter/double click a row to open in Steam Workshop." From 5fa8498c56a29665784ddad6bdeb272c03bff7eb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:36:42 +0900 Subject: [PATCH 10/31] fix: clear linting errors --- helpers/servers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers/servers.py b/helpers/servers.py index 2d6b1c0..5636710 100644 --- a/helpers/servers.py +++ b/helpers/servers.py @@ -249,10 +249,10 @@ def details(ip: str, qport: int) -> Details: for keyword in keywords: if "etm" in keyword: day_accel = float(keyword.lstrip("etm")) - day_accel = f"{day_accel:g}" + day_accel = float(f"{day_accel:g}") if "entm" in keywords: night_accel = float(keyword.lstrip("entm")) - night_accel = f"{night_accel:g}" + night_accel = float(f"{night_accel:g}") try: password = info.password_protected From ffe3dac3a8a93a11f16f9ba9c43e9c89a45650b8 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:38:06 +0900 Subject: [PATCH 11/31] fix: mods downloading too fast --- helpers/funcs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/helpers/funcs b/helpers/funcs index 47cc655..ea846a5 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1626,6 +1626,9 @@ concat_mods(){ local encoded_id local link for i in "${concat_arr[@]}"; do + until [[ -f $workshop_dir/$i/meta.cpp ]]; do + sleep 0.1s + done id=$(awk -F"= " '/publishedid/ {print $2}' "$workshop_dir"/$i/meta.cpp | awk -F\; '{print $1}') encoded_id=$(encode $id) link="@$encoded_id;" From 6f3b272bfe46bfc4f2a85e8cdfa650b6902f4d37 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:38:47 +0900 Subject: [PATCH 12/31] fix: floating point syntax with printf --- helpers/funcs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/funcs b/helpers/funcs index ea846a5..b9c208f 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -453,7 +453,7 @@ get_dist(){ else logger INFO "Resolved remote server geolocation to '$remote_lat, $remote_lon'" local dist=$($km_helper "$local_lat" "$local_lon" "$remote_lat" "$remote_lon") - LC_NUMERIC=C printf "%d" "$dist" + LC_NUMERIC=C printf "%.0f" "$dist" logger INFO "Distance: $dist km" fi } From c40d767dcc8ef5f5b6ba3c9bddce62e60f9a4013 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:39:25 +0900 Subject: [PATCH 13/31] chore: drop extraneous logging --- helpers/funcs | 1 - 1 file changed, 1 deletion(-) diff --git a/helpers/funcs b/helpers/funcs index b9c208f..bf6fe5b 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1705,7 +1705,6 @@ manual_mod_install(){ _watcher(){ for((i=0;i<${#stage_mods[@]};i++)); do [[ -f $ex ]] && return 1 - log ${stage_mods[$i]} if [[ $mode == "auto" ]] || [[ $mode == "force" ]]; then $steam_cmd "steam://url/CommunityFilePage/${stage_mods[$i]}+workshop_download_item $aid ${stage_mods[$i]}" From da6cc9c5c3d6dc68c8b30861a47048ebd70cf68c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:39:43 +0900 Subject: [PATCH 14/31] chore: reword log message --- helpers/funcs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/funcs b/helpers/funcs index bf6fe5b..b654040 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1778,7 +1778,7 @@ foreground(){ } main(){ local params="$(printf '"%s", ' "$@")" - logger INFO "Received request from UI constructor with params [${params::-2}]" + logger INFO "Received request from UI with params [${params::-2}]" func=${funcs["$1"]} [[ -z $func ]] && return 1 if [[ -z $2 ]]; then From 90409774477006be9f7dd28f70e62acf4a3ff209 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:42:09 +0900 Subject: [PATCH 15/31] chore: drop defunct methods --- helpers/funcs | 355 -------------------------------------------------- 1 file changed, 355 deletions(-) diff --git a/helpers/funcs b/helpers/funcs index b654040..ccfba46 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -82,7 +82,6 @@ forum_url="https://old.reddit.com/r/dzgui" sponsor_url="$gh_prefix/sponsors/$author" battlemetrics_server_url="https://www.battlemetrics.com/servers/dayz" steam_api_url="https://steamcommunity.com/dev/apikey" -#TODO: update link in docs battlemetrics_api_url="https://www.battlemetrics.com/developers" bm_api="https://api.battlemetrics.com/servers" @@ -94,7 +93,6 @@ fi declare -A funcs=( ["Highlight stale"]="find_stale_mods" -["My servers"]="dump_servers" ["Change player name"]="update_config_val" ["Change Steam API key"]="update_config_val" ["Change Battlemetrics API key"]="update_config_val" @@ -108,9 +106,6 @@ declare -A funcs=( ["find_id"]="find_id" ["toggle"]="toggle" ["Open link"]="open_link" -["filter"]="dump_servers" -["dump_servers"]="dump_servers" -["get_unique_maps"]="get_unique_maps" ["get_dist"]="get_dist" ["test_cooldown"]="test_cooldown" ["query_config"]="query_config" @@ -133,20 +128,9 @@ declare -A funcs=( ["Resolve IP"]="resolve_ip" ["Handshake"]="final_handshake" ["get_player_count"]="get_player_count" -["lan_scan"]="lan_scan" ["update_symlinks"]="update_symlinks" ) -lan_scan(){ - local port="$1" - local res - res=$("$lan_helper" "$port") - if [[ -z $res ]]; then - printf "\n" - else - printf "%s\n" "$res" - fi -} query_favorites(){ if [[ -z "${ip_list[@]}" ]]; then return 1 @@ -329,13 +313,6 @@ start_cooldown(){ logger WARN "API response empty. Started 60s cooldown at $(date +%s)" date +%s > $_cache_cooldown } -initialize_remote_servers(){ - local file="$_cache_servers" - [[ -f $file ]] && rm "$file" - local res - res=$(get_remote_servers) - parse_server_json "$res" >> "$file" -} is_dlc(){ local dlc local ip="$1" @@ -457,56 +434,6 @@ get_dist(){ logger INFO "Distance: $dist km" fi } -get_remote_servers(){ - params=( - "\\nor\1\map\chernarusplus\\nor\1\map\sakhal\\nor\1\map\enoch\empty\1\\nor\1\map\namalsk" - "\map\namalsk\empty\1" - "\map\namalsk\noplayers\1" - "\map\chernarusplus\empty\1" - "\map\chernarusplus\noplayers\1" - "\map\\sakhal\empty\1" - "\map\\sakhal\noplayers\1" - "\map\\enoch\empty\1" - "\map\\enoch\noplayers\1" - ) - local limit=10000 - local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?" - - _fetch(){ - local param="$1" - curl -LsG "$url" \ - -d filter="\appid\221100${param}" \ - -d limit=$limit \ - -d key=$steam_api \ - | jq -M -r '.response.servers' - - } - - for ((i=0; i <${#params[@]}; i++ )); do - _fetch "${params[$i]}" > $_cache_temp.${i} - done - - # ubuntu variants do not support jq 1.7 chained operators - # https://github.com/jqlang/jq/releases/tag/jq-1.7 - jq -n '[ [inputs]|add ][]' $_cache_temp.* && rm $_cache_temp.* -} -get_unique_maps(){ - shift - local context="$1" - local filter_file - case "$context" in - "My saved servers") - filter_file="$_cache_my_servers" - ;; - "Server browser") - filter_file="$_cache_servers" - ;; - "Recent servers") - filter_file="$_cache_history" - esac - logger INFO "Map filter context is: '$context', using cached file at '$filter_file'" - < "$filter_file" awk -F$separator '{print $2}' | sort -u -} query_config(){ [[ -n $2 ]] && local key=$2 keys=( @@ -531,154 +458,6 @@ query_config(){ echo "${!i}" done } -filter_servers(){ - local filtered="$(< "$1")" - shift - readarray -t filters < <(printf "%s\n" "$@") - - for ((i=0; i< ${#filters[@]}; ++i)); do - if [[ ${filters[$i]} =~ Keyword ]]; then - keyword=$(<<< ${filters[$i]} awk -F␞ '{print $2}') - elif [[ ${filters[$i]} =~ Map ]]; then - map=$(<<< ${filters[$i]} awk -F= '{print $2}') - fi - done - - filter_ascii(){ - if [[ ${filters[*]} =~ Non ]]; then - echo -n "$filtered" - else - <<< "$filtered" sed 's/␞/@@DZGUI_PLACEHOLDER@@/g' | grep -v -P '[^[:ascii:]]' | sed 's/@@DZGUI_PLACEHOLDER@@/␞/g' - fi - } - filter_time(){ - if [[ ${filters[*]} =~ Day ]] && [[ ${filters[*]} =~ Night ]]; then - echo -n "$filtered" - elif [[ ${filters[*]} =~ Day ]]; then - <<< "$filtered" awk -F$separator '$4~/^([0][6-9]:|[1][0-6])/' - elif [[ ${filters[*]} =~ Night ]]; then - <<< "$filtered" awk -F$separator '$4~/^([1][7-9]:|[2][0-3]:|[0][0-5])/' - else - echo -n "" - fi - } - filter_perspective(){ - if [[ ${filters[*]} =~ 1PP ]] && [[ ${filters[*]} =~ 3PP ]]; then - echo -n "$filtered" - elif [[ ${filters[*]} =~ 1PP ]]; then - <<< "$filtered" awk '!/3PP/' - elif [[ ${filters[*]} =~ 3PP ]]; then - <<< "$filtered" awk '!/1PP/' - else - echo -n "" - fi - } - filter_lowpop(){ - if [[ ${filters[*]} =~ Low ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '{if (($5 > 0) && ($5/$6)*100 >=30){print $0}}' - fi - } - filter_full(){ - if [[ ${filters[*]} =~ Full ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '$5 != $6' - fi - } - filter_empty(){ - if [[ ${filters[*]} =~ Empty ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '$5 != "0"' - fi - } - filter_map(){ - if [[ $map == "All maps" ]]; then - echo "$filtered" - else - <<< "$filtered" awk -v var="$map" -F$separator '$2 == var' - fi - } - filter_keyword(){ - keyword=$(sanitize "$keyword") - <<< "$filtered" awk -F$separator -v keyword="$keyword" 'tolower($0) ~ tolower(keyword)' - } - filter_duplicates(){ - if [[ ${filters[*]} =~ Duplicate ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '!seen[$1]++' - fi - } - filter_official(){ - if [[ ${filters[*]} =~ Official ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '$10 == "Community"' - fi - } - filter_community(){ - if [[ ${filters[*]} =~ Unoffic ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '$10 == "Official"' - fi - } - - filtered=$(filter_perspective) - filtered=$(filter_full) - filtered=$(filter_empty) - filtered=$(filter_time) - filtered=$(filter_map) - filtered=$(filter_lowpop) - filtered=$(filter_ascii) - filtered=$(filter_duplicates) - filtered=$(filter_keyword) - filtered=$(filter_official) - filtered=$(filter_community) - - if [[ -z "$filtered" ]]; then - logger WARN "Filter result is empty" - echo -n "" - return - fi - - logger INFO "Returning sorted server list back to UI" - printf "%s\n" "$filtered" | sort -k1 -} -sanitize(){ - echo "$1" | sed \ - -e 's/\//\\\//g' \ - -e 's/\$/\\$/g' \ - -e 's/\[/\\[/g' \ - -e 's/\]/\\]/g' \ - -e 's/\#/\\#/g' \ - -e 's/\./\\./g' \ - -e 's/\^/\\^/g' \ - -e 's/\=/\\=/g' \ - -e 's/|/\\|/g' \ - -e 's/\+/\\+/g' \ - -e 's/(/\\(/g' \ - -e 's/)/\\)/g' -} -parse_server_json(){ - local response="$1" - # some servers pad SOH in name - <<< "$response" sed 's/\\u0001//g' | jq -r ' - .[]|"\(.name)␞" + - "\(.map|if type == "string" then ascii_downcase else "null" end)␞" + - "\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "no3rd") then "1PP" else "3PP" end) end)␞" + - "\(if .gametype == null then "null" else (.gametype as $time|$time|test("[0-9]{2}:[0-9]{2}$") as $match|(if $match == true then ($time|scan("[0-9]{2}:[0-9]{2}$")) else "XXXX" end)) end)␞" + - "\(.players)␞" + - "\(.max_players)␞" + - "\(if .gametype == null then "0" elif .gametype|split("lqs")[1] == null then "0" else .gametype|split("lqs")[1]|split(",")[0] end)␞" + - "\(.addr|split(":")[0]):\(if .gameport == null then "XXXX" else .gameport end)␞" + - "\(.addr|split(":")[1])␞" + - "\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "external") then "Community" else "Official" end) end)" - ' | sort -k1 -} align_versions_file(){ shift local mod="$1" @@ -737,62 +516,6 @@ test_cooldown(){ exit 1 fi } -dump_servers(){ - local context="$1" - local subcontext="$2" - local ip - local qport - local res - _iterate(){ - local file="$1" - shift - for server in "$@"; do - ip=$(<<< $server awk -F: '{print $1}') - qport=$(<<< $server awk -F: '{print $3}') - res=$(a2s "$ip" "$qport" info) - if [[ ! $? -eq 0 ]]; then - continue - fi - parse_server_json "$res" >> "$file" - done - } - case "$subcontext" in - *Server[[:space:]]browser*) - local file="$_cache_servers" - if [[ ! $context =~ filter ]]; then - initialize_remote_servers - fi - ;; - *My[[:space:]]saved[[:space:]]servers*) - local file="$_cache_my_servers" - if [[ ! $context =~ filter ]]; then - [[ -f $file ]] && rm $file - _iterate "$file" "${ip_list[@]}" - fi - ;; - *Recent[[:space:]]servers*) - local file="$_cache_history" - if [[ ! $context =~ filter ]]; then - [[ -f $file ]] && rm $file - readarray -t iters < <(cat $history_file) - _iterate "$file" "${iters[@]}" - fi - ;; - *Scan[[:space:]]LAN[[:space:]]servers*) - local port=$(<<< "$subcontext" awk -F: '{print $2}') - local file="$_cache_lan" - if [[ ! $context =~ filter ]]; then - [[ -f $file ]] && rm $file - local lan=$(lan_scan $port) - readarray -t iters <<< "$lan" - _iterate "$file" "${iters[@]}" - fi - ;; - esac - shift - logger INFO "Server context is '$subcontext', reading from file '$file'" - filter_servers "$file" "$@" -} redact(){ sed 's@\(/home/\)[^/]*@\1REDACTED@g' } @@ -1214,11 +937,6 @@ connect_from_table(){ local record="$1" try_connect "$record" } -pretty_print(){ - while read -r line; do - printf "\t%s\n" "$line" - done < "$@" -} generate_log(){ source $config_file cat <<-DOC > $system_log @@ -1451,68 +1169,6 @@ check_architecture(){ echo 0 fi } -focus_beta_client(){ - _wid(){ - wmctrl -ilx |\ - awk 'tolower($3) == "steamwebhelper.steam"' |\ - awk '$5 ~ /^Steam|Steam Games List/' |\ - awk '{print $1}' - } - $steam_cmd steam://open/library 2>/dev/null 1>&2 && - $steam_cmd steam://open/console 2>/dev/null 1>&2 && - sleep 1s - until [[ -n $(_wid) ]]; do - sleep 0.1s - done - wmctrl -ia $(_wid) - sleep 0.1s - - local wid=$(xdotool getactivewindow) - local geo=$(xdotool getwindowgeometry $wid) - local pos=$(<<< "$geo" awk 'NR==2 {print $2}' | sed 's/,/ /') - local dim=$(<<< "$geo" awk 'NR==3 {print $2}' | sed 's/x/ /') - local pos1=$(<<< "$pos" awk '{print $1}') - local pos2=$(<<< "$pos" awk '{print $2}') - local dim1=$(<<< "$dim" awk '{print $1}') - local dim2=$(<<< "$dim" awk '{print $2}') - local dim1=$(((dim1/2)+pos1)) - local dim2=$(((dim2/2)+pos2)) - - xdotool mousemove $dim1 $dim2 - xdotool click 1 - $steam_cmd steam://open/library 2>/dev/null 1>&2 && - $steam_cmd steam://open/console 2>/dev/null 1>&2 -} -auto_mod_install(){ - # currently unused, merged with manual method - local ip="$1" - local gameport="$2" - local diff="$3" - local sanitized_mods="$4" - - console_dl "$diff" && - $steam_cmd steam://open/downloads - - local total=$(<<< "$diff" wc -l) - until [[ -z $(compare "$diff") ]]; do - local missing=$(compare "$diff" | wc -l) - echo "# Downloaded $(($total-missing)) of $total mods. ESC cancels" - done | $steamsafe_zenity --pulsate --progress --title="DZG Watcher" --auto-close --no-cancel --width=500 2>/dev/null - if [[ ! $? -eq 0 ]]; then - echo "User aborted connect process. Steam may have mods pending for download." - exit 1 - fi - - local diff=$(compare "$sanitized_mods") - - if [[ -z $diff ]]; then - #wipe old version file and replace with latest stamps - rm "$versions_file" - check_timestamps - logger INFO "Local modlist matches remote, initiating launch request" - launch "$ip" "$gameport" "$sanitized_mods" - fi -} force_update(){ if [[ ! $auto_install -eq 1 ]]; then printf "Only available when mod auto-install is ON" @@ -1524,17 +1180,6 @@ force_update(){ echo "Finished requesting mod updates." return 0 } -console_dl(){ - readarray -t modids <<< "$@" - focus_beta_client - sleep 1.5s - for i in "${modids[@]}"; do - xdotool type --delay 0 "workshop_download_item $aid $i" - sleep 0.5s - xdotool key Return - sleep 0.5s - done -} get_local_stamps(){ readarray -t modlist < <(printf "%s\n" "$@") local max="${#modlist[@]}" From 8ffb1c7ea6fe3582ab84b9be2d29aee961a35bfe Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:43:14 +0900 Subject: [PATCH 16/31] fix: version file not available yet --- helpers/funcs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/helpers/funcs b/helpers/funcs index ccfba46..8c20d94 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1218,8 +1218,6 @@ check_timestamps(){ local aligned=$(<<< "$local_stamps" jq -r '.response.publishedfiledetails[]|"\(.publishedfileid),\(.time_updated)"') readarray -t remote_ids < <(<<< "$aligned" awk -F, '{print $1}') readarray -t remote_times < <(<<< "$aligned" awk -F, '{print $2}') - readarray -t old_ids < <(< $versions_file awk -F, '{print $1}') - readarray -t old_times < <(< $versions_file awk -F, '{print $2}') if [[ ! -f $versions_file ]]; then logger INFO "No prior versions file found, creating" @@ -1229,6 +1227,9 @@ check_timestamps(){ return 0 fi + readarray -t old_ids < <(< $versions_file awk -F, '{print $1}') + readarray -t old_times < <(< $versions_file awk -F, '{print $2}') + declare -A remote_version declare -A local_version From ee73c2cfa35348dbe98ddccc6ba6b3e7208cb585 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:45:49 +0900 Subject: [PATCH 17/31] feat: parse pefile --- helpers/funcs | 123 ++++------- helpers/pefile.py | 499 +++++++++++++++++++++++++++++++++++++++++++++ helpers/servers.py | 107 +++++++++- helpers/ui.py | 347 ++++++++++++++++++++++++++----- 4 files changed, 935 insertions(+), 141 deletions(-) create mode 100644 helpers/pefile.py diff --git a/helpers/funcs b/helpers/funcs index 8c20d94..2a41ece 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -4,6 +4,7 @@ version="6.0.0-beta.5" #CONSTANTS aid=221100 +exp=1024020 game="dayz" app_name="dzgui" app_name_upper="DZGUI" @@ -46,6 +47,7 @@ _cache_my_servers="$cache_dir/$prefix.my_servers" _cache_history="$cache_dir/$prefix.history" _cache_launch="$cache_dir/$prefix.launch_mods" _cache_address="$cache_dir/$prefix.launch_address" +_cache_binary="$cache_dir/$prefix.binary" _cache_coords="$cache_path/$prefix.coords" _cache_cooldown="$cache_path/$prefix.cooldown" _cache_lan="$cache_path/$prefix.lan" @@ -97,12 +99,9 @@ declare -A funcs=( ["Change Steam API key"]="update_config_val" ["Change Battlemetrics API key"]="update_config_val" ["Change favorite server"]="add_record" -["Quick-connect to favorite server"]="quick_connect" ["Add server by IP"]="add_record" ["Add server by ID"]="add_record" -["Connect by IP"]="validate_and_connect" -["Connect by ID"]="validate_and_connect" -["Connect from table"]="connect_from_table" +["try_connect"]="try_connect" ["find_id"]="find_id" ["toggle"]="toggle" ["Open link"]="open_link" @@ -126,11 +125,26 @@ declare -A funcs=( ["Remove from history"]="remove_from_history" ["Force update local mods"]="force_update" ["Resolve IP"]="resolve_ip" -["Handshake"]="final_handshake" +["Handshake"]="handshake" +["Handshake_EXP"]="handshake_exp" ["get_player_count"]="get_player_count" ["update_symlinks"]="update_symlinks" ) +clone_symlinks(){ + local path="$(< $_cache_binary)" + path=$(dirname "$path") + for dir in $(find $game_dir -type l); do + local link=$(basename $dir) + ln -sf "${dir}" "${path}/${link}" + done +} +handshake(){ + final_handshake "$aid" +} +handshake_exp(){ + final_handshake "$exp" +} query_favorites(){ if [[ -z "${ip_list[@]}" ]]; then return 1 @@ -191,44 +205,6 @@ get_player_count(){ printf "%s\n%s" "$players" "$queue" } -validate_and_connect(){ - local context="$1" - local addr="$2" - - local record - case "$context" in - "Connect by ID") - if [[ -z "$api_key" ]]; then - printf "No Battlemetrics API key set" - return 4 - fi - record=$(map_id_to_ip "$addr") - if [[ $? -eq 1 ]]; then - logger WARN "Not a valid record: '$addr'" - printf "Not a valid ID" - return 2 - fi - logger INFO "Battlemetrics ID resolved to IP $record" - ;; - "Connect by IP") - if [[ $(validate_ip "$addr") -eq 1 ]]; then - printf "Not a valid IP format. Supply IP:Queryport" - return 2 - fi - local ip=$(<<< $addr awk -F: '{print $1}') - local qport=$(<<< $addr awk -F: '{print $2}') - local res - res=$(a2s $ip $qport info) - if [[ ! $? -eq 0 ]]; then - printf "Timed out when querying the server. Is this a valid server?" - return 2 - fi - local gameport="$(<<< $res jq -r '.[].gameport')" - record="${ip}:${gameport}:${qport}" - logger INFO "Record resolved to $record" - esac - try_connect "$record" -} map_id_to_ip(){ local id="$1" local res=$(curl -s "$bm_api" -H "Authorization: Bearer "$api_key"" \ @@ -294,21 +270,6 @@ add_record(){ ;; esac } -connect_by_id(){ - if [[ $(validate_ip "$addr") -eq 1 ]]; then - printf "Not a valid IP format. Supply IP:Queryport" - return 2 - fi - local ip=$(<<< $addr awk -F: '{print $1}') - local qport=$(<<< $addr awk -F: '{print $2}') - local res - res=$(a2s $ip $qport info) - if [[ ! $? -eq 0 ]]; then - printf "Timed out when querying the server. Is this a valid server?" - return 2 - fi - #res contains modlist -} start_cooldown(){ logger WARN "API response empty. Started 60s cooldown at $(date +%s)" date +%s > $_cache_cooldown @@ -444,6 +405,7 @@ query_config(){ "fav_label" "preferred_client" "fullscreen" + "default_steam_path" ) if [[ -n $key ]]; then if [[ -n ${!key} ]]; then @@ -924,19 +886,6 @@ open_link(){ xdg-open "$url" fi } - -quick_connect(){ - if [[ -z $fav_server ]]; then - printf "No favorite server currently set" - return 1 - fi - try_connect "$fav_server" -} -connect_from_table(){ - shift - local record="$1" - try_connect "$record" -} generate_log(){ source $config_file cat <<-DOC > $system_log @@ -1126,10 +1075,17 @@ try_fallback(){ esac } try_connect(){ + shift local record="$1" + local appid="$2" + local binary="$3" + local ip=$(<<< $record awk -F: '{print $1}') local gameport=$(<<< $record awk -F: '{print $2}') local qport=$(<<< $record awk -F: '{print $3}') + + echo "$binary" > $_cache_binary + local remote_mods remote_mods=$(a2s $ip $qport rules) if [[ $? -eq 1 ]]; then @@ -1154,11 +1110,11 @@ try_connect(){ return 1 fi case $auto_install in - "") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods";; - 1|2) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" ;; + "") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "$appid";; + 1|2) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" "$appid" ;; esac else - launch "$ip" "$gameport" "$sanitized_mods" + launch "$ip" "$gameport" "$sanitized_mods" "$appid" fi } check_architecture(){ @@ -1176,7 +1132,7 @@ force_update(){ fi rm "$versions_file" local update=$(check_timestamps) - manual_mod_install "null" "null" "$update" "null" "force" + manual_mod_install "null" "null" "$update" "null" "force" "null" echo "Finished requesting mod updates." return 0 } @@ -1293,6 +1249,8 @@ launch(){ local ip="$1" local gameport="$2" local mods="$3" + local appid="$4" + local concat if [[ -n $mods ]]; then concat=$(concat_mods "$mods") @@ -1301,18 +1259,22 @@ launch(){ fi update_symlinks + [[ $appid -eq $exp ]] && clone_symlinks + if [[ $debug -eq 1 ]]; then - local launch_options="$steam_cmd -applaunch $aid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro -mod=$concat" + local launch_options="$steam_cmd -applaunch $appid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro -mod=$concat" printf "Debug mode: these options would have been used to launch the game: $launch_options" return 0 fi echo "$concat" > "$_cache_launch" echo "$ip:$gameport" > "$_cache_address" logger INFO "Saved launch params: '$concat'" - printf "Launch conditions satisfied. DayZ will now launch after you confirm this dialog." - return 100 + printf "Launch conditions satisfied. DayZ will launch after you confirm this dialog." + [[ $appid == "$aid" ]] && return 100 + [[ $appid == "$exp" ]] && return 101 } final_handshake(){ + local appid="$1" local saved_mods=$(< "$_cache_launch") local saved_address=$(< "$_cache_address") local res=$(is_dayz_running) @@ -1329,7 +1291,7 @@ final_handshake(){ params+=("-skipintro") params+=("-name=$name") params+=("-mod=$saved_mods") - $steam_cmd -applaunch $aid "${params[@]}" & + $steam_cmd -applaunch $appid "${params[@]}" & until [[ $(is_dayz_running) -eq 1 ]]; do sleep 0.1s done @@ -1343,6 +1305,7 @@ manual_mod_install(){ local diff="$3" local sanitized_mods="$4" local mode="$5" + local appid="$6" local ex="$state_path/dzg.watcher" readarray -t stage_mods <<< "$diff" @@ -1400,7 +1363,7 @@ manual_mod_install(){ rm "$versions_file" check_timestamps fi - launch "$ip" "$gameport" "$sanitized_mods" + launch "$ip" "$gameport" "$sanitized_mods" "$appid" else printf "User aborted download process, or some mods may have failed to download. Try connecting again to resync." exit 1 diff --git a/helpers/pefile.py b/helpers/pefile.py new file mode 100644 index 0000000..0b026d0 --- /dev/null +++ b/helpers/pefile.py @@ -0,0 +1,499 @@ +import json +import struct +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 + +# https://learn.microsoft.com/en-us/windows/win32/debug/pe-format +endian = "<" +IMAGE_DIRECTORY_ENTRY = 2 +VERSION_RESOURCE = 16 +RESOURCE_NODE = ".rsrc" +PE32_x86 = "0x10b" +PE32_x64 = "0x20b" +VS_VERSION_INFO_MAGIC = "0xfeef04bd0000" +VS_VERSION_INFO_ID = "VS_VERSION_INFO" + + +class VersionMatch(Enum): + LOCAL_OLDER = 1 + LOCAL_NEWER = 2 + SAME_VERSION = 3 + FAIL = 4 + + +class u8: + fmt = "B" + + +class u16: + fmt = "H" + + +class u32: + fmt = "L" + + +class u64: + fmt = "Q" + + +class i8: + fmt = "b" + + +class i16: + fmt = "h" + + +class i32: + fmt = "l" + + +class i64: + fmt = "q" + + +class PackedData: + @classmethod + def unpack(cls, data: BinaryIO): + r = [] + for key, value in cls.__annotations__.items(): + if value == str: + f = data.read(8).rstrip(b"\x00\x00").decode() + else: + fmt = endian + (value.fmt) + size = struct.calcsize(fmt) + f = struct.unpack(fmt, data.read(size))[0] + r.append(f) + return cls(*r) + + +@dataclass(slots=True, frozen=True) +class COFF_FILE_HDR(PackedData): + machine_type: u16 + number_of_sections: u16 + timestamp: u32 + pointer_to_symbol_table: u32 + number_of_symbols: u32 + size_of_optional_header: u16 + characteristics: u16 + + +@dataclass(slots=True, frozen=True) +class OPTIONAL_HDR_X86(PackedData): + magic: u16 + major_linker_ver: u8 + minor_linker_ver: u8 + size_of_code: u32 + size_of_initialized_data: u32 + size_of_uninitialized_data: u32 + address_of_entry_point: u32 + base_of_code: u32 + base_of_data: u32 + + +@dataclass(slots=True, frozen=True) +class OPTIONAL_HDR_X64(PackedData): + magic: u16 + major_linker_ver: u8 + minor_linker_ver: u8 + size_of_code: u32 + size_of_initialized_data: u32 + size_of_uninitialized_data: u32 + address_of_entry_point: u32 + base_of_code: u32 + + +@dataclass(slots=True, frozen=True) +class OPTIONAL_HDR_WIN_X86(PackedData): + image_base: u32 + section_alignment: u32 + file_alignment: u32 + major_operating_system_version: u16 + minor_operating_system_version: u16 + major_image_version: u16 + minor_image_version: u16 + major_subsystem_version: u16 + minor_subsystem_version: u16 + win32_version_value: u32 + size_of_image: u32 + size_of_headers: u32 + checksum: u32 + subsystem: u16 + dll_characteristics: u16 + size_of_stack_reserve: u32 + size_of_stack_commit: u32 + size_of_heap_reserve: u32 + size_of_heap_commit: u32 + loader_flags: u32 + number_of_rva_and_sizes: u32 + + +@dataclass(slots=True, frozen=True) +class OPTIONAL_HDR_WIN_X64(PackedData): + image_base: u64 + section_alignment: u32 + file_alignment: u32 + major_operating_system_version: u16 + minor_operating_system_version: u16 + major_image_version: u16 + minor_image_version: u16 + major_subsystem_version: u16 + minor_subsystem_version: u16 + win32_version_value: u32 + size_of_image: u32 + size_of_headers: u32 + checksum: u32 + subsystem: u16 + dll_characteristics: u16 + size_of_stack_reserve: u64 + size_of_stack_commit: u64 + size_of_heap_reserve: u64 + size_of_heap_commit: u64 + loader_flags: u32 + number_of_rva_and_sizes: u32 + + +@dataclass(slots=True, frozen=True) +class DATA_DIR(PackedData): + virtual_address: u32 + size: u32 + + +@dataclass(slots=True, frozen=True) +class SECTION_HDR(PackedData): + name: str + virtual_size: u32 + virtual_address: u32 + size_of_raw_data: u32 + pointer_to_raw_data: u32 + pointer_to_relocations: u32 + pointer_to_line_numbers: u32 + number_of_relocations: u16 + number_of_line_numbers: u16 + characteristics: u32 + + +@dataclass(slots=True, frozen=True) +class RESOURCE_DIRECTORY_TABLE(PackedData): + characteristics: u32 + timestamp: u32 + major_version: u16 + minor_version: u16 + number_of_name_entries: u16 + number_of_id_entries: u16 + + +@dataclass(slots=True, frozen=True) +class RESOURCE_DIRECTORY_ENTRY(PackedData): + """ + This field is either a string identifying a data leaf + (if the high bit is set) or an ID to another nested directory + (if the high bit is clear). The outermost level is always a + directory. If it is a name, the lower 31 bits are the offset from the + beginning of the resource section's raw data to the name + (the name consists of 16 bits length and trailing wide characters, + in Unicode, not 0-terminated). + """ + name_or_id: u32 + data_or_subdir: u32 + + +@dataclass(slots=True, frozen=True) +class RESOURCE_DATA_ENTRY(PackedData): + data_rva: u32 + size: u32 + codepage: u32 + reserved: u32 + + +@dataclass(slots=True, frozen=True) +class VS_VERSION_INFO_HDR(PackedData): + size: u16 + value_length: u16 + value_type: u16 + + +@dataclass(slots=True, frozen=True) +class DayZVersion: + major: int + minor: int + patch: int + + +@dataclass(slots=True, frozen=True) +class FileVersion: + major: int + minor: int + build: int + revision: int + + +@dataclass(slots=True, frozen=True) +class Result: + local: Union[str, None] + remote: str + build: str + path: Union[Path, None] + match: VersionMatch + error: Union[Exception, None] + + +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 + return FileVersion(major, minor, build, revision) + + +def seek_to_hex(address: str, data: BinaryIO) -> None: + data.seek(int(address, base=16)) + + +def seek_to_pe_stub(data: BinaryIO) -> None: + MAGIC = "0x3c" + seek_to_hex(MAGIC, data) + e_lfanew = hex(struct.unpack(" DayZVersion: + version = get_version(file) + 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 get_version(file): + with open(file, "rb") as f: + seek_to_pe_stub(f) + COFF = COFF_FILE_HDR.unpack(f) + + pos = f.tell() + blob = f.read() + magic = hex(struct.unpack(" VERSION_RESOURCE: + raise PeFileError("no version info node found") + seek_to_hex(hex(offset + entry.data_or_subdir), f) + + data = RESOURCE_DATA_ENTRY.unpack(f) + # https://stackoverflow.com/questions/2170843/va-virtual-address-rva-relative-virtual-address + offset = data.data_rva - hdr.virtual_address + hdr.pointer_to_raw_data + seek_to_hex(hex(offset), f) + + hdr = VS_VERSION_INFO_HDR.unpack(f) + # https://learn.microsoft.com/en-us/windows/win32/menurc/vs-versioninfo + byte_len = len(VS_VERSION_INFO_ID.encode("utf-16le")) + label = f.read(byte_len).decode("utf-16le") + if label != VS_VERSION_INFO_ID: + raise PeFileError(f"header identifier != '{VS_VERSION_INFO_ID}'") + f.read(32 - byte_len) + + identifier = hex(struct.unpack(" 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 18/31] 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 19/31] 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 20/31] 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 21/31] 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 22/31] 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 23/31] 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 24/31] 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 25/31] 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 26/31] 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 27/31] 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 28/31] 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 29/31] 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 30/31] 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 31/31] 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