Compare commits

...

9 Commits

Author SHA1 Message Date
aclist
81c2d19a09 chore: update checksums
Some checks are pending
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2025-09-11 17:46:32 +09:00
aclist
b500806815 chore: drop dependency 2025-09-11 17:45:58 +09:00
aclist
60ce184d8f fix: whitespace 2025-09-11 17:10:16 +09:00
aclist
2edb81247b chore: update checksums 2025-09-11 17:09:59 +09:00
aclist
7b67222885 fix: move docstring 2025-09-11 17:09:28 +09:00
aclist
4ddb33cabb chore: update checksums 2025-09-11 17:08:06 +09:00
aclist
364f0b3482 chore: update dataclass params 2025-09-11 17:06:39 +09:00
aclist
5482f296a5 chore: formatting 2025-09-11 17:01:57 +09:00
aclist
0d52c83ed9 chore: refactor pefile methods 2025-09-11 16:58:58 +09:00
4 changed files with 184 additions and 149 deletions

View File

@ -587,10 +587,10 @@ fetch_helpers_by_sum(){
sums=( sums=(
["funcs"]="f1db0e8b1068defdf834e9c9510bf315" ["funcs"]="f1db0e8b1068defdf834e9c9510bf315"
["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067"
["servers.py"]="3610debc3f2931d2aa7c002ae912db88" ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf"
["ui.py"]="567b39cefb08f66f63f77cae5fa0c94f" ["ui.py"]="3258d5c85ef22517277c213ee2fc9b1d"
["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397"
["pefile.py"]="cc23ff2725fedb1c64908f77477360b6" ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0"
) )
local author="aclist" local author="aclist"
local repo="dztui" local repo="dztui"

View File

@ -4,7 +4,6 @@ import typing # noqa
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from packaging.version import Version
from pathlib import Path from pathlib import Path
from shlex import shlex from shlex import shlex
from typing import BinaryIO, Union from typing import BinaryIO, Union
@ -24,7 +23,6 @@ class VersionMatch(Enum):
LOCAL_OLDER = 1 LOCAL_OLDER = 1
LOCAL_NEWER = 2 LOCAL_NEWER = 2
SAME_VERSION = 3 SAME_VERSION = 3
FAIL = 4
class u8: class u8:
@ -201,6 +199,7 @@ class RESOURCE_DIRECTORY_ENTRY(PackedData):
(the name consists of 16 bits length and trailing wide characters, (the name consists of 16 bits length and trailing wide characters,
in Unicode, not 0-terminated). in Unicode, not 0-terminated).
""" """
name_or_id: u32 name_or_id: u32
data_or_subdir: u32 data_or_subdir: u32
@ -247,30 +246,34 @@ class Result:
class PeFileError(Exception): class PeFileError(Exception):
"""Expected contents missing from headers or resource nodes""" """Expected contents missing from headers or resource nodes"""
pass pass
class AppNotInstalledError(Exception): class AppNotInstalledError(Exception):
"""App not present in user's libraryfolders""" """App not present in user's libraryfolders"""
pass pass
class AppMovedError(Exception): class AppMovedError(Exception):
"""VDF points to a nonexistent location on disk""" """VDF points to a nonexistent location on disk"""
pass pass
class VDFLoadError(Exception): class VDFLoadError(Exception):
"""Malformed VDF or JSON conversion""" """Malformed VDF or JSON conversion"""
pass pass
def parse_version_number(data: BinaryIO): def parse_version_number(data: BinaryIO):
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo # https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xffff minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xffff major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xffff build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
revision = struct.unpack("<L", data.read(4))[0] >> 16 & 0xffff revision = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
return FileVersion(major, minor, build, revision) return FileVersion(major, minor, build, revision)
@ -288,17 +291,24 @@ def seek_to_pe_stub(data: BinaryIO) -> None:
raise PeFileError("missing PE header data") raise PeFileError("missing PE header data")
def get_dayz_version(file: Path) -> DayZVersion: def get_dayz_version(file: Path) -> DayZVersion | Exception:
try:
version = get_version(file) version = get_version(file)
except Exception as e:
return e
patch = str(version.build) + str(version.revision) patch = str(version.build) + str(version.revision)
dz_vers = DayZVersion(version.major, version.minor, int(patch)) dz_vers = DayZVersion(version.major, version.minor, int(patch))
return dz_vers return dz_vers
def get_dayz_version_str(file: Path) -> str: def dayz_version_to_str(v: DayZVersion) -> str:
v = get_dayz_version(file) return ".".join(str(el) for el in [v.major, v.minor, v.patch])
concat = ".".join(str(el) for el in [v.major, v.minor, v.patch])
return concat
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): def get_version(file):
@ -352,8 +362,8 @@ def get_version(file):
seek_to_hex(hex(offset + shift), f) seek_to_hex(hex(offset + shift), f)
table = RESOURCE_DIRECTORY_TABLE.unpack(f) table = RESOURCE_DIRECTORY_TABLE.unpack(f)
total = ( total = (
table.number_of_name_entries + table.number_of_name_entries
table.number_of_id_entries + table.number_of_id_entries
) )
for entry in range(total): for entry in range(total):
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f) entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
@ -389,14 +399,6 @@ def get_version(file):
return version 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: def get_pefile_path(path: str, appid: int) -> Path:
binary = "DayZ_x64.exe" binary = "DayZ_x64.exe"
identifier = {221100: "DayZ", 1024020: "DayZ Exp"} identifier = {221100: "DayZ", 1024020: "DayZ Exp"}
@ -431,69 +433,60 @@ def get_pefile_path(path: str, appid: int) -> Path:
return pe_path return pe_path
def compare_versions(remote: str, appid: int, path: str): def compare_versions(local: DayZVersion, remote: DayZVersion):
if appid == 221100: """
build = "DayZ" packaging.version module is not available OOTB on some distributions
else: """
build = "DayZ Experimental" if dayz_version_to_str(local) == dayz_version_to_str(remote):
return VersionMatch.SAME_VERSION
local = None if local.major < remote.major:
pe_filepath = None return VersionMatch.LOCAL_OLDER
error = None if local.major > remote.major:
return VersionMatch.LOCAL_NEWER
try: if local.major == remote.major:
pe_filepath = get_pefile_path(path, appid) if local.minor < remote.minor:
except Exception as e: return VersionMatch.LOCAL_OLDER
return Result( if local.minor > remote.minor:
local, remote, build, pe_filepath, VersionMatch.FAIL, e return VersionMatch.LOCAL_NEWER
) if local.minor == remote.minor:
if local.patch < remote.patch:
try: return VersionMatch.LOCAL_OLDER
local = get_dayz_version_str(pe_filepath) if local.patch > remote.patch:
except PeFileError: return VersionMatch.LOCAL_NEWER
return Result( if local.patch == remote.patch:
local, remote, build, pe_filepath, VersionMatch.FAIL, error return VersionMatch.SAME_VERSION
)
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 vdf_to_json(stream):
def _istr(indent, string): def _istr(indent, string):
return (indent * ' ') + string return (indent * " ") + string
jbuf = '{\n' jbuf = "{\n"
lex = shlex(stream) lex = shlex(stream)
indent = 1 indent = 1
while True: while True:
tok = lex.get_token() tok = lex.get_token()
if not tok: if not tok:
return jbuf + '}\n' return jbuf + "}\n"
if tok == '}': if tok == "}":
indent -= 1 indent -= 1
jbuf += _istr(indent, '}') jbuf += _istr(indent, "}")
ntok = lex.get_token() ntok = lex.get_token()
lex.push_token(ntok) lex.push_token(ntok)
if ntok and ntok != '}': if ntok and ntok != "}":
jbuf += ',' jbuf += ","
jbuf += '\n' jbuf += "\n"
else: else:
ntok = lex.get_token() ntok = lex.get_token()
if ntok == '{': if ntok == "{":
jbuf += _istr(indent, tok + ': {\n') jbuf += _istr(indent, tok + ": {\n")
indent += 1 indent += 1
else: else:
jbuf += _istr(indent, tok + ': ' + ntok) jbuf += _istr(indent, tok + ": " + ntok)
ntok = lex.get_token() ntok = lex.get_token()
lex.push_token(ntok) lex.push_token(ntok)
if ntok != '}': if ntok != "}":
jbuf += ',' jbuf += ","
jbuf += '\n' jbuf += "\n"

View File

@ -201,7 +201,7 @@ def query_direct(ip: str, qport: int, TIMEOUT=3.0) -> dict | None:
return None return None
@dataclass @dataclass(slots=True, frozen=True)
class Res: class Res:
status: int status: int
parsed: bool parsed: bool
@ -215,14 +215,14 @@ class Ping:
ping: int ping: int
@dataclass @dataclass(slots=True, frozen=True)
class Details: class Details:
data: Union[list, None] data: Union[list, None]
description: str description: str
success: bool success: bool
@dataclass @dataclass(slots=True, frozen=True)
class Prereqs: class Prereqs:
password: bool password: bool
gameport: int gameport: int
@ -232,6 +232,9 @@ class Prereqs:
@dataclass(slots=True) @dataclass(slots=True)
class Record: class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str ip: str
gameport: int gameport: int
qport: int qport: int

View File

@ -23,12 +23,18 @@ from typing import Literal, Self, Any
import servers as Servers # noqa E402 import servers as Servers # noqa E402
import pefile as PeFile # noqa E402 import pefile as PeFile # noqa E402
from pefile import VDFLoadError, AppNotInstalledError, AppMovedError, PeFileError from pefile import (
from pefile import VersionMatch VDFLoadError,
AppNotInstalledError,
AppMovedError,
PeFileError,
)
from pefile import VersionMatch, DayZVersion
locale.setlocale(locale.LC_ALL, "") locale.setlocale(locale.LC_ALL, "")
import gi # noqa E402 import gi # noqa E402
gi.require_version("Gtk", "3.0") 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
@ -93,7 +99,7 @@ If this issue persists, your API key may be defunct.
""" """
@dataclass @dataclass(slots=True)
class Record: class Record:
ip: str ip: str
gameport: int gameport: int
@ -725,7 +731,7 @@ def format_metadata(row_sel: str) -> str:
"fav_label": config_vals[4], "fav_label": config_vals[4],
"preferred_client": config_vals[5], "preferred_client": config_vals[5],
"fullscreen": config_vals[6], "fullscreen": config_vals[6],
"default_steam_path": config_vals[7] "default_steam_path": config_vals[7],
} }
if row is None: if row is None:
return "" return ""
@ -936,6 +942,7 @@ def call_on_thread(
""" """
Exclusively used for threaded subprocesses Exclusively used for threaded subprocesses
""" """
def _background(subproc: str, args: str, dialog): def _background(subproc: str, args: str, dialog):
def _load() -> None: def _load() -> None:
wait_dialog.destroy() wait_dialog.destroy()
@ -1011,7 +1018,7 @@ def process_tree_option(choice: RowType) -> None:
parse_shell_output, parse_shell_output,
"Querying server", "Querying server",
command, command,
[record] [record],
) )
return return
@ -1045,9 +1052,8 @@ def thread_new_with_dialog(
callback: Callable | None, callback: Callable | None,
msg: str, msg: str,
row: RowType | None, row: RowType | None,
args: list args: list,
) -> None: ) -> None:
""" """
Pop a GenericDialog transient to App.treeview and Pop a GenericDialog transient to App.treeview and
call a function on a thread, with optional callback. call a function on a thread, with optional callback.
@ -1128,11 +1134,7 @@ def connect_by_ip(enum: RowType, response: str) -> None:
return proc return proc
thread_new_with_dialog( thread_new_with_dialog(
_prep, _prep, parse_shell_output, "Querying IP", enum, [response]
parse_shell_output,
"Querying IP",
enum,
[response]
) )
return return
@ -1144,11 +1146,7 @@ def connect_by_id(enum: RowType, response: str, key: str) -> None:
return proc return proc
thread_new_with_dialog( thread_new_with_dialog(
_prep, _prep, parse_shell_output, "Querying API", enum, [key, response]
parse_shell_output,
"Querying API",
enum,
[key, response]
) )
return return
@ -1161,7 +1159,9 @@ def process_user_input(enum: RowType) -> None:
if enum == RowType.CONN_BY_ID: if enum == RowType.CONN_BY_ID:
key = query_config("api_key")[0] key = query_config("api_key")[0]
if len(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 return
user_entry = EntryDialog(prompt, Popup.ENTRY, link_label) user_entry = EntryDialog(prompt, Popup.ENTRY, link_label)
@ -2349,7 +2349,10 @@ class TreeView(Gtk.TreeView):
params = Servers.params params = Servers.params
serv = [] serv = []
with ThreadPoolExecutor() as executor: 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) wait(futures)
for future in futures: for future in futures:
res = future.result() res = future.result()
@ -2855,62 +2858,96 @@ class TreeView(Gtk.TreeView):
def get_view(self): def get_view(self):
return self.view 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 Always called on a thread with a dialog on the transient parent window
""" """
prereqs = Servers.get_prereqs(record.ip, record.qport) prereqs = Servers.get_prereqs(record.ip, record.qport)
if prereqs.appid is None: 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" msg = "Timed out when querying server, check IP or try again later"
spawn_dialog(msg, Popup.NOTIFY) spawn_dialog(msg, Popup.NOTIFY)
return None return None
if prereqs.version is not None: build = "DayZ" if prereqs.appid == APPID_DAYZ else "DayZ Experimental"
path = query_config("default_steam_path")[0] steam_path = query_config("default_steam_path")[0]
result = PeFile.compare_versions(prereqs.version, prereqs.appid, path)
if result.error is not None: if len(steam_path) < 1:
logger.warning(result.error) 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: try:
if isinstance(result.error, VDFLoadError) or isinstance(result.error, PeFileError): pefile_path = PeFile.get_pefile_path(steam_path, prereqs.appid)
# permissive; file exists, but could not determine version except AppNotInstalledError:
pass logger.critical(
if isinstance(result.error, AppNotInstalledError): f"'{prereqs.appid}' not found in user's libraryfolders"
if prereqs.appid == 1024020: )
msg = ( msg = (
"This server is running DayZ Experimental, a beta build. " f"This server is running {build}. "
"You can install DayZ Experimental by searching for it in " f"You can install {build} by searching for it in "
"your Steam library." "your Steam library."
) )
spawn_dialog(msg, Popup.NOTIFY) spawn_dialog(msg, Popup.NOTIFY)
return None return None
if isinstance(result.error, AppMovedError): except AppMovedError:
logger.critical(
f"Library folder synch error for '{prereqs.appid}'"
)
msg = ( msg = (
f"Steam is reporting that {result.build} is installed at a non-existent location. " f"Steam is reporting that {build} is installed at a non-existent location. "
f"If you recently installed {result.build} or moved it to a different drive, " f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again." "restart Steam to allow these changes to synchronize, then try again."
) )
spawn_dialog(msg, Popup.NOTIFY) spawn_dialog(msg, Popup.NOTIFY)
return None 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
if result.match == VersionMatch.LOCAL_OLDER: 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 = ( msg = (
f"This server is running a newer build ({result.remote}) of {result.build} than " f"This server is running a newer build ({prereqs.version}) of {build} than "
f"your local version ({result.local}). You may be unable to connect. Proceed anyway?" f"your local version. You may be unable to connect. Proceed anyway?"
) )
res = spawn_dialog(msg, Popup.CONFIRM) res = spawn_dialog(msg, Popup.CONFIRM)
if res is True: if res is True:
return None return None
case VersionMatch.LOCAL_NEWER:
if result.match == VersionMatch.LOCAL_NEWER:
msg = ( msg = (
f"This server is running an out-of-date build ({result.remote}) of {result.build}. " f"This server is running an out-of-date build ({prereqs.version}) of {build}. "
"You may be unable to connect. Proceed anyway?" "You may be unable to connect. Proceed anyway?"
) )
res = spawn_dialog(msg, Popup.CONFIRM) res = spawn_dialog(msg, Popup.CONFIRM)
if res is True: if res is True:
return None return None
case VersionMatch.SAME_VERSION:
pass
if prereqs.password is True: if prereqs.password is True:
msg = ( msg = (
@ -2924,14 +2961,10 @@ class TreeView(Gtk.TreeView):
""" """
When using RowType.CONN_BY_IP, the gameport needs to be interpolated When using RowType.CONN_BY_IP, the gameport needs to be interpolated
""" """
record.gameport = prereqs.gameport record.gameport = prereqs.gameport
addr = record_to_str(record) addr = record_to_str(record)
proc = call_out( proc = call_out(
"try_connect", "try_connect", addr, str(prereqs.appid), str(pefile_path)
addr,
str(prereqs.appid),
str(result.path)
) )
return proc return proc
@ -3008,7 +3041,7 @@ class TreeView(Gtk.TreeView):
parse_shell_output, parse_shell_output,
"Querying server", "Querying server",
None, None,
[record] [record],
) )
case _: # any other non-server option from the main menu case _: # any other non-server option from the main menu
process_tree_option(output) process_tree_option(output)
@ -3362,7 +3395,7 @@ class DetailsDialog(GenericDialog):
reg = r"\s(www\.*?)" reg = r"\s(www\.*?)"
text = re.sub(reg, " http://" + r"\1", text) text = re.sub(reg, " http://" + r"\1", text)
reg2 = r"(http.*?)([ ,\r\n]|$)" 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.description.set_markup(text)
self.success = response.success self.success = response.success
@ -3727,7 +3760,7 @@ class Options(Gtk.Box):
version_rows = [ version_rows = [
[LeftLabel("DayZ"), self.dayz_version_label], [LeftLabel("DayZ"), self.dayz_version_label],
[LeftLabel("DayZ Experimental"), self.dayz_exp_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) api_grid = self._make_grid(api_rows)
@ -4016,14 +4049,20 @@ class Options(Gtk.Box):
field[1].get_children()[1].set_sensitive(False) field[1].get_children()[1].set_sensitive(False)
try: try:
pe_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ) pe_file_path = PeFile.get_pefile_path(
dayz_version = PeFile.get_dayz_version_str(pe_file_path) default_steam_path, APPID_DAYZ
)
vers = PeFile.get_dayz_version(pe_file_path)
dayz_version = PeFile.dayz_version_to_str(vers)
except Exception: except Exception:
dayz_version = "-" dayz_version = "-"
try: try:
exp_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ_EXP) exp_file_path = PeFile.get_pefile_path(
dayz_exp_version = PeFile.get_dayz_version_str(exp_file_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: except Exception:
dayz_exp_version = "-" dayz_exp_version = "-"