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