Upload files to "/"

This commit is contained in:
x3
2026-09-21 11:09:15 +00:00
commit ff94d6c4e5
2 changed files with 856 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+856
View File
@@ -0,0 +1,856 @@
#!/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()