Files
BeamMP-Installer-Proton/beammp-gui.py
T
2026-09-21 11:09:15 +00:00

857 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
BeamMP GUI Setup für BeamNG.drive (Proton)
- lädt BeamMP-Launcher.exe von GitHub herunter
- installiert ihn in das BeamNG-Protonprefix (compatdata/284160)
- trägt BeamMP als Non-Steam-Game in Steam ein (inkl. Proton-CompatToolMapping)
Getestet auf Gentoo, Steam-Layout mit mehreren Bibliotheken (/mnt/Games + ~/.local/share/Steam).
Benötigt: python3 + PyGObject (Gtk 3). Kein tkinter nötig.
Start: ~/scripts/beammp-gui.py oder python3 ~/scripts/beammp-gui.py
"""
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib
import os, re, sys, shutil, struct, binascii, json
import urllib.request, threading, subprocess, time
BEAMNG_APPID = "284160"
# Windows-Launcher (funktioniert im Proton-Prefix). Quelle: GitHub Releases.
BEAMMP_EXE_URL = "https://github.com/BeamMP/BeamMP-Launcher/releases/latest/download/BeamMP-Launcher.exe"
BEAMMP_FALLBACK_URL = "https://beammp.com/installer/BeamMP_Installer.zip"
CONFIG_FILE = os.path.expanduser("~/.config/beammp-gui/config.json")
WRAPPER_NAME = "beammp-launch.sh"
# --------------------------------------------------------------------------
# Steam / BeamNG Erkennung
# --------------------------------------------------------------------------
def find_steam_root():
candidates = [
os.path.expanduser("~/.local/share/Steam"),
os.path.expanduser("~/.steam/steam"),
os.path.expanduser("~/.steam/root"),
]
for p in candidates:
if os.path.isfile(os.path.join(p, "steamapps", "libraryfolders.vdf")):
return p
if os.path.isdir(os.path.join(p, "steamapps")):
return p
return None
def parse_libraryfolders(steam_root):
"""Liest libraryfolders.vdf und gibt Liste von Bibliotheks-Pfaden zurück."""
libs = []
if steam_root and os.path.isdir(steam_root):
libs.append(steam_root)
lf = os.path.join(steam_root or "", "steamapps", "libraryfolders.vdf")
if os.path.isfile(lf):
try:
with open(lf, errors="replace") as f:
for line in f:
m = re.search(r'"path"\s+"([^"]+)"', line)
if m:
p = m.group(1).replace("\\\\", "/").replace("\\", "/")
# Steam escapt ggf. doppelte Slashes
p = os.path.normpath(p)
if os.path.isdir(p) and p not in libs:
libs.append(p)
except Exception:
pass
# Deduplizieren, existierende zuerst
out = []
for p in libs:
if p not in out:
out.append(p)
return out
def parse_acf_installdir(acf_path):
try:
with open(acf_path, errors="replace") as f:
for line in f:
m = re.search(r'"installdir"\s+"([^"]+)"', line)
if m:
return m.group(1)
except Exception:
return None
return None
def find_beamng():
"""
Gibt dict zurück: {game_dir, library, prefix, compatdata}
prefix = .../compatdata/284160/pfx
compatdata = .../compatdata/284160
"""
steam = find_steam_root()
if not steam:
return None
libs = parse_libraryfolders(steam)
# 1. per appmanifest suchen (korrekte AppID 284160!)
for lib in libs:
acf = os.path.join(lib, "steamapps", f"appmanifest_{BEAMNG_APPID}.acf")
if os.path.isfile(acf):
installdir = parse_acf_installdir(acf) or "BeamNG.drive"
game_dir = os.path.join(lib, "steamapps", "common", installdir)
if os.path.isdir(game_dir):
compat = os.path.join(lib, "steamapps", "compatdata", BEAMNG_APPID)
prefix = os.path.join(compat, "pfx")
return {"game_dir": game_dir, "library": lib,
"compatdata": compat, "prefix": prefix, "steam": steam}
# 2. Fallback: common/BeamNG.drive Verzeichnis-Scan
for lib in libs:
gd = os.path.join(lib, "steamapps", "common", "BeamNG.drive")
if os.path.isdir(gd) and os.path.isfile(os.path.join(gd, "BeamNG.drive.exe")):
compat = os.path.join(lib, "steamapps", "compatdata", BEAMNG_APPID)
return {"game_dir": gd, "library": lib,
"compatdata": compat, "prefix": os.path.join(compat, "pfx"),
"steam": steam}
return None
def find_proton_versions():
"""Liste von (name, proton_binary, tool_dir)."""
steam = find_steam_root()
if not steam:
return []
libs = parse_libraryfolders(steam)
search_bases = []
for lib in libs:
search_bases.append(os.path.join(lib, "steamapps", "common"))
search_bases.append(os.path.join(lib, "compatibilitytools.d"))
# zusätzlich user-weite tools
search_bases.append(os.path.expanduser("~/.local/share/Steam/compatibilitytools.d"))
search_bases.append(os.path.join(steam, "compatibilitytools.d"))
found = {}
for base in search_bases:
if not os.path.isdir(base):
continue
try:
for name in sorted(os.listdir(base)):
low = name.lower()
if "proton" not in low and "ge-" not in low and "wine" not in low:
continue
tool_dir = os.path.join(base, name)
proton_bin = os.path.join(tool_dir, "proton")
if os.path.isfile(proton_bin) and name not in found:
found[name] = (proton_bin, tool_dir)
except Exception:
continue
return [(n, pb, td) for n, (pb, td) in sorted(found.items())]
def proton_to_compat_name(folder_name):
low = folder_name.lower()
if "experimental" in low:
return "proton_experimental"
m = re.search(r"proton\s*0*(\d+)", low)
if m:
return f"proton_{m.group(1)}"
if "ge-proton" in low:
# GE-Proton: Steam nutzt internen Namen, Ordnername funktioniert meist nicht.
# Fallback auf proton_9 + Hinweis; Mapping wird trotzdem gesetzt.
mm = re.search(r"(\d+)", low)
if mm:
return f"proton_{mm.group(1)}"
return "proton_9"
if "hotfix" in low:
return "proton_hotfix"
return "proton_9"
def steam_running():
# steam.pid existiert + Prozess läuft, oder pgrep steam
for cmd in (["pgrep", "-x", "steam"], ["pgrep", "-x", "steamwebhelper"]):
try:
r = subprocess.run(cmd, capture_output=True, timeout=5)
if r.returncode == 0 and r.stdout.strip():
return True
except Exception:
pass
return False
# --------------------------------------------------------------------------
# shortcuts.vdf (binär) lesen/schreiben – typ-erhaltend!
# Typen: 0x00 = Objekt, 0x01 = String, 0x02 = int32, 0x08 = Ende
# --------------------------------------------------------------------------
def _read_cstr(data, pos):
end = data.index(b"\x00", pos)
s = data[pos:end].decode("utf-8", errors="replace")
return s, end + 1
def _parse_obj(data, pos):
"""Parst Objekt-Inhalt ab pos, gibt (dict, neue_pos) zurück. End-Byte 0x08 wird konsumiert."""
obj = {}
order = [] # Key-Reihenfolge + Typ merken
n = len(data)
while pos < n:
t = data[pos]
pos += 1
if t == 0x08:
break
key, pos = _read_cstr(data, pos)
if t == 0x01:
val, pos = _read_cstr(data, pos)
obj[key] = val
order.append((key, 0x01))
elif t == 0x02:
val = struct.unpack_from("<i", data, pos)[0]
pos += 4
obj[key] = val
order.append((key, 0x02))
elif t == 0x00:
val, pos = _parse_obj(data, pos)
obj[key] = val
order.append((key, 0x00))
else:
raise ValueError(f"Unbekannter VDF-Typ {t:#x} bei Key {key!r}")
obj["_order"] = order
return obj, pos
def read_shortcuts_vdf(path):
"""Gibt (top_dict, order_list) zurück. top_dict: {'0': {...}, '1': {...}}"""
with open(path, "rb") as f:
data = f.read()
if not data:
return {}, []
pos = 0
if data[pos] != 0x00:
raise ValueError("Kein gültiges shortcuts.vdf (erwartet 0x00 am Anfang)")
pos += 1
root_key, pos = _read_cstr(data, pos) # "shortcuts"
top, pos = _parse_obj(data, pos)
order = top.pop("_order", [])
for v in top.values():
if isinstance(v, dict):
v.pop("_order", None)
keys = [k for k, _ in order]
return top, keys
def _write_cstr(f, s):
f.write(s.encode("utf-8", errors="replace") + b"\x00")
def _write_obj(f, obj, order=None):
items = order if order else [(k, None) for k in obj.keys() if not k.startswith("_")]
seen = set()
for key, forced_t in items:
if key.startswith("_") or key not in obj:
continue
seen.add(key)
val = obj[key]
if isinstance(val, dict):
f.write(b"\x00")
_write_cstr(f, key)
sub_order = val.get("_order")
_write_obj(f, {k: v for k, v in val.items() if not k.startswith("_")}, sub_order)
elif isinstance(val, int) and not isinstance(val, bool):
f.write(b"\x02")
_write_cstr(f, key)
f.write(struct.pack("<i", val))
else:
f.write(b"\x01")
_write_cstr(f, key)
_write_cstr(f, str(val))
for key, val in obj.items():
if key.startswith("_") or key in seen:
continue
if isinstance(val, dict):
f.write(b"\x00")
_write_cstr(f, key)
_write_obj(f, {k: v for k, v in val.items() if not k.startswith("_")}, val.get("_order"))
elif isinstance(val, int) and not isinstance(val, bool):
f.write(b"\x02")
_write_cstr(f, key)
f.write(struct.pack("<i", val))
else:
f.write(b"\x01")
_write_cstr(f, key)
_write_cstr(f, str(val))
f.write(b"\x08")
def write_shortcuts_vdf(path, top, keys):
tmp = path + ".tmp"
with open(tmp, "wb") as f:
f.write(b"\x00")
_write_cstr(f, "shortcuts")
# Einträge in numerischer Reihenfolge
for i, k in enumerate(keys):
entry = top[k]
f.write(b"\x00")
_write_cstr(f, str(i))
_write_obj(f, entry)
f.write(b"\x08\x08") # Objekt-Ende + Root-Terminator (wie Steam selbst)
os.replace(tmp, path)
def steam_shortcut_appid(exe, appname):
"""Standard Steam-Algorithmus: crc32(exe + appname) | 0x80000000 als signed int32."""
crc = binascii.crc32((exe + appname).encode("utf-8")) & 0xFFFFFFFF
crc |= 0x80000000
return struct.unpack("<i", struct.pack("<I", crc))[0]
def ensure_compat_mapping(config_vdf_path, unsigned_id, tool_name, priority="250"):
"""Trägt Proton-Mapping in config.vdf ein (Text-VDF). Backup wird erstellt."""
with open(config_vdf_path, errors="replace") as f:
text = f.read()
key = str(unsigned_id)
# Bereits vorhanden? Dann Namen ersetzen.
pat = re.compile(r'"' + re.escape(key) + r'"\s*\{\s*"name"\s*"[^"]*"\s*"config"\s*"[^"]*"\s*"priority"\s*"[^"]*"\s*\}', re.DOTALL)
block = (f'\t\t\t\t\t"{key}"\n\t\t\t\t\t{{\n'
f'\t\t\t\t\t\t"name"\t\t"{tool_name}"\n'
f'\t\t\t\t\t\t"config"\t\t""\n'
f'\t\t\t\t\t\t"priority"\t\t"{priority}"\n\t\t\t\t\t}}')
if pat.search(text):
text = pat.sub(block, text)
else:
# Vor der schließenden Klammer von "CompatToolMapping" einfügen:
# Suche den Block und füge vor dem letzten "}," auf derselben Ebene ein.
m = re.search(r'"CompatToolMapping"\s*\n\s*\{', text)
if not m:
raise ValueError("CompatToolMapping-Block in config.vdf nicht gefunden")
# Finde zugehörige schließende Klammer per Klammerzähler ab m.end()
depth = 1
i = m.end()
in_str = False
esc = False
while i < len(text) and depth > 0:
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
else:
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
i += 1
if depth != 0:
raise ValueError("config.vdf Klammern unausgeglichen")
text = text[:i] + "\n" + block + "\n\t\t\t\t" + text[i:]
backup = config_vdf_path + ".beammp-gui.bak"
shutil.copy2(config_vdf_path, backup)
with open(config_vdf_path, "w") as f:
f.write(text)
return backup
def remove_compat_mapping(config_vdf_path, unsigned_id):
"""Entfernt Proton-Mapping wieder (Wrapper läuft nativ). Gibt True zurück wenn etwas entfernt wurde."""
with open(config_vdf_path, errors="replace") as f:
text = f.read()
key = str(unsigned_id)
pat = re.compile(r'\s*"' + re.escape(key) + r'"\s*\{\s*"name"\s*"[^"]*"\s*"config"\s*"[^"]*"\s*"priority"\s*"[^"]*"\s*\}', re.DOTALL)
if not pat.search(text):
return False
text = pat.sub("", text, count=1)
backup = config_vdf_path + ".beammp-gui.bak"
shutil.copy2(config_vdf_path, backup)
with open(config_vdf_path, "w") as f:
f.write(text)
return True
# --------------------------------------------------------------------------
# Download / Installation
# --------------------------------------------------------------------------
def download_file(url, dest, log, progress_cb=None):
req = urllib.request.Request(url, headers={"User-Agent": "beammp-gui/1.0"})
with urllib.request.urlopen(req, timeout=120) as resp:
total = int(resp.headers.get("Content-Length") or 0)
got = 0
chunk = 1024 * 256
with open(dest, "wb") as f:
while True:
buf = resp.read(chunk)
if not buf:
break
f.write(buf)
got += len(buf)
if progress_cb:
GLib.idle_add(progress_cb, got, total)
return dest
def official_launcher_dest(prefix):
"""Offizieller Ablageort im Prefix (wie Windows %AppData%)."""
return os.path.join(prefix, "drive_c", "users", "steamuser",
"AppData", "Roaming", "BeamMP-Launcher", "BeamMP-Launcher.exe")
# --------------------------------------------------------------------------
# GUI
# --------------------------------------------------------------------------
class MainWindow(Gtk.ApplicationWindow):
def __init__(self, app):
super().__init__(application=app, title="BeamMP für BeamNG (Proton) – Setup")
self.set_default_size(760, 660)
self.set_border_width(10)
self.config = self._load_config()
self.beamng = None
self._build_ui()
GLib.idle_add(self.refresh)
# -- config --
def _load_config(self):
try:
if os.path.isfile(CONFIG_FILE):
with open(CONFIG_FILE) as f:
return json.load(f)
except Exception:
pass
return {}
def _save_config(self):
try:
self.config.update({
"launcher_path": self.ent_launcher.get_text(),
"steam_name": self.ent_name.get_text(),
"proton": self.cmb_proton.get_active_text() or "",
})
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(self.config, f, indent=2)
except Exception:
pass
# -- UI --
def _build_ui(self):
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add(vbox)
# Status
fr = Gtk.Frame(label=" Erkennung ")
vbox.pack_start(fr, False, False, 0)
sb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
sb.set_border_width(6)
fr.add(sb)
self.lbl_steam = Gtk.Label(xalign=0, selectable=True)
self.lbl_beamng = Gtk.Label(xalign=0, selectable=True)
self.lbl_prefix = Gtk.Label(xalign=0, selectable=True)
self.lbl_proton = Gtk.Label(xalign=0, selectable=True)
for lab in (self.lbl_steam, self.lbl_beamng, self.lbl_prefix, self.lbl_proton):
lab.set_line_wrap(True)
sb.pack_start(lab, False, False, 1)
# Einstellungen
fr2 = Gtk.Frame(label=" Einstellungen ")
vbox.pack_start(fr2, False, False, 0)
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
grid.set_border_width(8)
fr2.add(grid)
grid.attach(Gtk.Label(label="BeamMP-Launcher (.exe):", xalign=0), 0, 0, 1, 1)
self.ent_launcher = Gtk.Entry(hexpand=True)
self.ent_launcher.set_text(self.config.get("launcher_path",
os.path.expanduser("~/Downloads/BeamMP-Launcher.exe")))
self.ent_launcher.set_tooltip_text("Pfad zur heruntergeladenen BeamMP-Launcher.exe")
grid.attach(self.ent_launcher, 1, 0, 1, 1)
hb = Gtk.Box(spacing=4)
b_dl = Gtk.Button(label="Herunterladen")
b_dl.connect("clicked", self.on_download)
b_br = Gtk.Button(label="...")
b_br.set_size_request(36, -1)
b_br.connect("clicked", self.on_browse_exe)
hb.pack_start(b_dl, False, False, 0)
hb.pack_start(b_br, False, False, 0)
grid.attach(hb, 2, 0, 1, 1)
grid.attach(Gtk.Label(label="Proton-Version:", xalign=0), 0, 1, 1, 1)
self.cmb_proton = Gtk.ComboBoxText(hexpand=True)
grid.attach(self.cmb_proton, 1, 1, 1, 1)
b_rf = Gtk.Button(label="Aktualisieren")
b_rf.connect("clicked", lambda *_: self.refresh())
grid.attach(b_rf, 2, 1, 1, 1)
grid.attach(Gtk.Label(label="Name in Steam:", xalign=0), 0, 2, 1, 1)
self.ent_name = Gtk.Entry(hexpand=True)
self.ent_name.set_text(self.config.get("steam_name", "BeamMP"))
grid.attach(self.ent_name, 1, 2, 2, 1)
self.progress = Gtk.ProgressBar(show_text=True)
self.progress.set_text("bereit")
grid.attach(self.progress, 0, 3, 3, 1)
# Aktionen
fr3 = Gtk.Frame(label=" Aktionen (Reihenfolge einhalten) ")
vbox.pack_start(fr3, False, False, 0)
hbox = Gtk.Box(spacing=6)
hbox.set_border_width(8)
fr3.add(hbox)
b1 = Gtk.Button(label="1. Im Prefix installieren")
b1.get_style_context().add_class("suggested-action")
b1.connect("clicked", self.on_install)
b2 = Gtk.Button(label="2. Zu Steam hinzufügen")
b2.get_style_context().add_class("suggested-action")
b2.connect("clicked", self.on_add_steam)
b_all = Gtk.Button(label="Alles nacheinander")
b_all.connect("clicked", self.on_all)
for b in (b1, b2, b_all):
hbox.pack_start(b, True, True, 0)
# Log
fr4 = Gtk.Frame(label=" Log ")
vbox.pack_start(fr4, True, True, 0)
sw = Gtk.ScrolledWindow()
sw.set_min_content_height(220)
fr4.add(sw)
self.log_buf = Gtk.TextBuffer()
self.log_view = Gtk.TextView(buffer=self.log_buf)
self.log_view.set_editable(False)
self.log_view.set_monospace(True)
self.log_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
sw.add(self.log_view)
self.show_all()
# -- helpers --
def log(self, msg):
def do():
it = self.log_buf.get_end_iter()
self.log_buf.insert(it, f"[{time.strftime('%H:%M:%S')}] {msg}\n")
mark = self.log_buf.create_mark(None, self.log_buf.get_end_iter(), False)
self.log_view.scroll_mark_onscreen(mark)
GLib.idle_add(do)
print(msg, flush=True)
def refresh(self):
steam = find_steam_root()
if steam:
self.lbl_steam.set_markup(f'<b>Steam:</b> <span fgcolor="green">OK</span> {GLib.markup_escape_text(steam)}')
else:
self.lbl_steam.set_markup('<b>Steam:</b> <span fgcolor="red">nicht gefunden</span>')
self.beamng = find_beamng()
if self.beamng:
self.lbl_beamng.set_markup(f'<b>BeamNG:</b> <span fgcolor="green">OK</span> {GLib.markup_escape_text(self.beamng["game_dir"])}')
pfx = self.beamng["prefix"]
if os.path.isdir(os.path.join(pfx, "drive_c")):
self.lbl_prefix.set_markup(f'<b>Prefix (284160):</b> <span fgcolor="green">OK</span> {GLib.markup_escape_text(pfx)}')
else:
self.lbl_prefix.set_markup(f'<b>Prefix (284160):</b> <span fgcolor="orange">noch nicht von Steam erstellt – bitte BeamNG einmal starten</span> {GLib.markup_escape_text(pfx)}')
else:
self.lbl_beamng.set_markup('<b>BeamNG:</b> <span fgcolor="red">nicht gefunden (AppID 284160 – bitte in Steam installieren)</span>')
self.lbl_prefix.set_markup('<b>Prefix:</b> <span fgcolor="red">–</span>')
pvs = find_proton_versions()
self.cmb_proton.remove_all()
if pvs:
self.lbl_proton.set_markup(f'<b>Proton:</b> <span fgcolor="green">{len(pvs)} gefunden</span>')
for name, _pb, _td in pvs:
self.cmb_proton.append_text(name)
saved = self.config.get("proton", "")
names = [n for n, _, _ in pvs]
self.cmb_proton.set_active(names.index(saved) if saved in names else 0)
else:
self.lbl_proton.set_markup('<b>Proton:</b> <span fgcolor="red">keins gefunden – bitte z.B. Proton 9 in Steam installieren</span>')
def _selected_proton(self):
name = self.cmb_proton.get_active_text()
if not name:
return None
for n, pb, td in find_proton_versions():
if n == name:
return (n, pb, td)
return None
# -- Aktionen --
def on_browse_exe(self, _):
dlg = Gtk.FileChooserDialog(title="BeamMP-Launcher.exe wählen",
action=Gtk.FileChooserAction.OPEN)
dlg.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
dlg.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
flt = Gtk.FileFilter()
flt.set_name("Windows-Executables")
flt.add_pattern("*.exe")
dlg.add_filter(flt)
if dlg.run() == Gtk.ResponseType.OK:
self.ent_launcher.set_text(dlg.get_filename())
dlg.destroy()
def on_download(self, _):
dest = self.ent_launcher.get_text().strip() or os.path.expanduser("~/Downloads/BeamMP-Launcher.exe")
if os.path.isdir(dest):
dest = os.path.join(dest, "BeamMP-Launcher.exe")
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
self.ent_launcher.set_text(dest)
self._save_config()
self.log(f"Download von {BEAMMP_EXE_URL} nach {dest} …")
def work():
try:
def prog(got, total):
if total:
self.progress.set_fraction(min(got / total, 1.0))
self.progress.set_text(f"{got//1024//1024} / {total//1024//1024} MB")
return False
try:
download_file(BEAMMP_EXE_URL, dest, self.log, prog)
except Exception as e1:
self.log(f"Primär-URL fehlgeschlagen ({e1}), versuche Fallback …")
download_file(BEAMMP_FALLBACK_URL, dest + ".zip", self.log, prog)
self.log(f"Fallback-ZIP gespeichert. Bitte BeamMP-Launcher.exe daraus entpacken. ({dest}.zip)")
return
size = os.path.getsize(dest)
if size < 1_000_000:
# evtl. HTML-Fehlerseite
with open(dest, "rb") as f:
head = f.read(200)
if b"<html" in head.lower():
raise ValueError("Server lieferte HTML statt .exe (Release evtl. umbenannt – siehe Log)")
self.log(f"Fertig: {size} Bytes → {dest}")
GLib.idle_add(self.progress.set_fraction, 1.0)
GLib.idle_add(self.progress.set_text, "Download fertig")
except Exception as e:
self.log(f"FEHLER beim Download: {e}")
self.log("Manuell laden: https://github.com/BeamMP/BeamMP-Launcher/releases/latest")
GLib.idle_add(self.progress.set_text, "Download fehlgeschlagen")
threading.Thread(target=work, daemon=True).start()
def on_install(self, _):
self._save_config()
if not self.beamng:
self.log("FEHLER: BeamNG.drive nicht gefunden – erst in Steam installieren.")
return
launcher = self.ent_launcher.get_text().strip()
if not launcher or not os.path.isfile(launcher):
self.log(f"FEHLER: Launcher nicht gefunden: {launcher} – erst herunterladen.")
return
sel = self._selected_proton()
if not sel:
self.log("FEHLER: keine Proton-Version gewählt.")
return
proton_name, proton_bin, _tool = sel
game_dir = self.beamng["game_dir"]
compat = self.beamng["compatdata"]
prefix = self.beamng["prefix"]
steam = self.beamng["steam"]
self.log(f"Installiere mit {proton_name} in Prefix {prefix} …")
threading.Thread(target=self._install_work, daemon=True,
args=(launcher, game_dir, compat, prefix, steam, proton_bin, proton_name)).start()
def _install_work(self, launcher, game_dir, compat, prefix, steam, proton_bin, proton_name):
try:
if not os.path.isdir(os.path.join(prefix, "drive_c")):
self.log("Prefix existiert noch nicht – bitte BeamNG.drive einmal über Steam starten, dann erneut versuchen.")
self.log(f"Erwartet: {prefix}/drive_c")
return
target = official_launcher_dest(prefix)
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copy2(launcher, target)
self.log(f"Launcher kopiert nach: {target}")
# Zweite Kopie für leichten Zugriff
alt = os.path.join(prefix, "drive_c", "BeamMP", "BeamMP-Launcher.exe")
os.makedirs(os.path.dirname(alt), exist_ok=True)
if os.path.abspath(alt) != os.path.abspath(target):
shutil.copy2(launcher, alt)
self.log(f"Kopie nach: {alt}")
# Einmalig via Proton starten, damit BeamMP Mod/Config angelegt werden.
env = os.environ.copy()
env["STEAM_COMPAT_DATA_PATH"] = compat
env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = steam
env["STEAM_COMPAT_INSTALL_PATH"] = game_dir
self.log("Starte Launcher einmalig via Proton (Fenster bestätigen, dann schließen) …")
self.log(f" STEAM_COMPAT_DATA_PATH={compat}")
proc = subprocess.Popen([proton_bin, "run", target], env=env, cwd=game_dir,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.log(f"Proton läuft (PID {proc.pid}) – bitte Installation im Fenster abschließen.")
try:
out, _ = proc.communicate(timeout=300)
self.log(f"Proton beendet (Code {proc.returncode}).")
if out:
self.log("Proton-Log (Auszug): " + out.strip().replace(chr(10), " | ")[:800])
except subprocess.TimeoutExpired:
self.log("Launcher läuft noch (Timeout 5 min) – Fenster manuell schließen, dann fortfahren.")
self.log("Schritt 1 fertig → jetzt Schritt 2 (Zu Steam hinzufügen).")
except Exception as e:
self.log(f"FEHLER bei Installation: {e}")
def on_add_steam(self, _):
self._save_config()
if not self.beamng:
self.log("FEHLER: BeamNG nicht gefunden.")
return
sel = self._selected_proton()
if not sel:
self.log("FEHLER: keine Proton-Version gewählt.")
return
proton_name, _pb, _td = sel
compat_name = proton_to_compat_name(proton_name)
name = self.ent_name.get_text().strip() or "BeamMP"
prefix = self.beamng["prefix"]
game_dir = self.beamng["game_dir"]
steam = self.beamng["steam"]
target = official_launcher_dest(prefix)
if not os.path.isfile(target):
# Fallback auf eingetragenen Pfad
launcher = self.ent_launcher.get_text().strip()
if launcher and os.path.isfile(launcher):
self.log("Launcher noch nicht im Prefix – kopiere jetzt …")
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copy2(launcher, target)
else:
self.log(f"FEHLER: {target} fehlt – erst Schritt 1 ausführen.")
return
if steam_running():
self.log("WARNUNG: Steam läuft! Bitte Steam vollständig beenden (Steam → Beenden), sonst wird shortcuts.vdf überschrieben.")
proton_bin = sel[1]
threading.Thread(target=self._steam_work, daemon=True,
args=(name, target, game_dir, steam, compat_name, proton_name, proton_bin)).start()
def _steam_work(self, name, exe_linux, startdir, steam, compat_name, proton_name, proton_bin):
try:
compatdata = self.beamng["compatdata"]
game_dir = self.beamng["game_dir"]
# Der Launcher braucht BeamNG.drive.ini im 284160-Prefix (installPath).
ini = os.path.join(self.beamng["prefix"], "drive_c", "users",
"steamuser", "AppData", "Local", "BeamNG", "BeamNG.drive.ini")
if not os.path.isfile(ini):
self.log("HINWEIS: BeamNG.drive.ini fehlt im Prefix – bitte BeamNG einmal normal über Steam starten, dann erneut versuchen.")
# Wrapper erzwingt das BeamNG-Prefix (284160). Direkt-Exe bekäme sonst
# ein eigenes frisches Prefix ohne ini -> Launcher-Fehler Code 7.
wrapper = os.path.join(os.path.expanduser("~/scripts"), WRAPPER_NAME)
with open(wrapper, "w") as f:
f.write("#!/bin/bash\n")
f.write("# BeamMP im BeamNG-Protonprefix (284160) starten. Wird vom GUI-Skript erzeugt.\n")
f.write("# Steam-Shortcut zeigt auf DIESE Datei (nativ, KEIN Proton-Compat erzwingen).\n")
f.write(f'export STEAM_COMPAT_DATA_PATH="{compatdata}"\n')
f.write(f'export STEAM_COMPAT_CLIENT_INSTALL_PATH="{steam}"\n')
f.write(f'export STEAM_COMPAT_INSTALL_PATH="{game_dir}"\n')
f.write(f'cd "{game_dir}"\n')
f.write(f'exec "{proton_bin}" run "{exe_linux}"\n')
os.chmod(wrapper, 0o755)
self.log(f"Wrapper-Skript: {wrapper}")
appid = steam_shortcut_appid(wrapper, name)
unsigned = appid & 0xFFFFFFFF
# Alte AppID (direkte Exe) zum Aufräumen des stale Compat-Mappings
old_appid = steam_shortcut_appid(exe_linux, name)
old_unsigned = old_appid & 0xFFFFFFFF
self.log(f"Shortcut-AppID: {appid} (unsigned {unsigned})")
for uid in sorted(os.listdir(os.path.join(steam, "userdata"))):
cfg = os.path.join(steam, "userdata", uid, "config")
if not os.path.isdir(cfg):
continue
vdf = os.path.join(cfg, "shortcuts.vdf")
if os.path.isfile(vdf):
shutil.copy2(vdf, vdf + ".beammp-gui.bak")
try:
top, keys = read_shortcuts_vdf(vdf) if os.path.isfile(vdf) else ({}, [])
except Exception as e:
self.log(f"{vdf} konnte nicht gelesen werden ({e}) – lege neu an.")
top, keys = {}, []
# Duplikat? (gleicher Name, alte direkte Exe oder Wrapper)
dup_key = None
for k, entry in top.items():
if entry.get("AppName") == name or entry.get("Exe") in (exe_linux, wrapper):
dup_key = k
break
entry = {
"appid": appid,
"AppName": name,
"Exe": wrapper,
"StartDir": startdir,
"icon": "",
"ShortcutPath": "",
"LaunchOptions": "",
"IsHidden": 0,
"AllowDesktopConfig": 1,
"AllowOverlay": 1,
"OpenVR": 0,
"Devkit": 0,
"DevkitGameID": "",
"DevkitOverrideAppID": 0,
"LastPlayTime": int(time.time()),
"FlatpakAppID": "",
"tags": {},
}
if dup_key is not None:
# Felder aktualisieren, LastPlayTime behalten falls neuer=0
old = top[dup_key]
old.update(entry)
old["appid"] = appid
self.log(f"Eintrag '{name}' aktualisiert (User {uid}).")
else:
new_index = str(max([int(k) for k in top.keys()] + [-1]) + 1)
top[new_index] = entry
keys = sorted(top.keys(), key=int)
self.log(f"Eintrag '{name}' neu erstellt (User {uid}, Index {new_index}).")
write_shortcuts_vdf(vdf, top, keys)
self.log(f"shortcuts.vdf geschrieben: {vdf}")
# Wrapper läuft NATIV (ruft selbst proton auf) -> KEIN Proton-Compat
# für den Shortcut erzwingen, sonst läuft der Wrapper doppelt unter Proton.
# Altes Mapping der direkten Exe ebenfalls entfernen.
config_vdf = os.path.join(steam, "config", "config.vdf")
if os.path.isfile(config_vdf):
try:
for uid_num in {unsigned, old_unsigned}:
if remove_compat_mapping(config_vdf, uid_num):
self.log(f"CompatToolMapping für {uid_num} entfernt (nativer Wrapper).")
self.log("Wichtig: In Steam bei BeamMP unter Eigenschaften → Kompatibilität "
"KEIN Häkchen setzen (nativ über Wrapper).")
except Exception as e:
self.log(f"Hinweis: CompatMapping-Aufräumen fehlgeschlagen ({e}).")
else:
self.log(f"Hinweis: {config_vdf} nicht gefunden.")
self.log("Fertig! Steam neu starten – BeamMP erscheint in der Bibliothek.")
self.log("Tipp: Falls Launcher Code 7 meldet, BeamNG einmal normal starten (erzeugt BeamNG.drive.ini).")
except Exception as e:
import traceback
self.log(f"FEHLER beim Steam-Eintrag: {e}")
self.log(traceback.format_exc(limit=3).replace(chr(10), " | "))
def on_all(self, _):
self.log("Führe Schritt 1 aus, Schritt 2 bitte nach Abschluss von 1 klicken.")
self.on_install(_)
class App(Gtk.Application):
def __init__(self):
super().__init__(application_id="com.beammp.gui")
self.win = None
def do_activate(self):
if not self.win:
self.win = MainWindow(self)
self.win.show_all()
self.win.present()
def main():
if "--detect" in sys.argv:
# Headless-Diagnose für Tests
print("steam:", find_steam_root())
print("libs:", parse_libraryfolders(find_steam_root() or ""))
print("beamng:", find_beamng())
print("proton:", [(n, pb) for n, pb, _ in find_proton_versions()])
return
app = App()
app.run()
if __name__ == "__main__":
main()