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=(
["funcs"]="f1db0e8b1068defdf834e9c9510bf315"
["query_v2.py"]="55d339ba02512ac69de288eb3be41067"
["servers.py"]="3610debc3f2931d2aa7c002ae912db88"
["ui.py"]="567b39cefb08f66f63f77cae5fa0c94f"
["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf"
["ui.py"]="3258d5c85ef22517277c213ee2fc9b1d"
["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397"
["pefile.py"]="cc23ff2725fedb1c64908f77477360b6"
["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0"
)
local author="aclist"
local repo="dztui"

View File

@ -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
@ -24,7 +23,6 @@ class VersionMatch(Enum):
LOCAL_OLDER = 1
LOCAL_NEWER = 2
SAME_VERSION = 3
FAIL = 4
class u8:
@ -201,6 +199,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
@ -247,30 +246,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("<L", data.read(4))[0] >> 16 & 0xffff
major = 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
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
major = 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
return FileVersion(major, minor, build, revision)
@ -288,17 +291,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):
@ -352,9 +362,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
@ -389,14 +399,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,69 +433,60 @@ 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):
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"

View File

@ -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
@ -232,6 +232,9 @@ class Prereqs:
@dataclass(slots=True)
class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str
gameport: int
qport: int

View File

@ -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 VersionMatch
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
@ -93,7 +99,7 @@ If this issue persists, your API key may be defunct.
"""
@dataclass
@dataclass(slots=True)
class Record:
ip: str
gameport: int
@ -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,62 +2858,96 @@ 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
"""
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 +2961,10 @@ 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)
"try_connect", addr, str(prereqs.appid), str(pefile_path)
)
return proc
@ -3008,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)
@ -3362,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
@ -3727,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)
@ -4016,14 +4049,20 @@ 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)
dayz_version = PeFile.get_dayz_version_str(pe_file_path)
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)
dayz_exp_version = PeFile.get_dayz_version_str(exp_file_path)
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:
dayz_exp_version = "-"
@ -4540,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",
},
]