#!/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(" 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'Steam: OK {GLib.markup_escape_text(steam)}') else: self.lbl_steam.set_markup('Steam: nicht gefunden') self.beamng = find_beamng() if self.beamng: self.lbl_beamng.set_markup(f'BeamNG: OK {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'Prefix (284160): OK {GLib.markup_escape_text(pfx)}') else: self.lbl_prefix.set_markup(f'Prefix (284160): noch nicht von Steam erstellt – bitte BeamNG einmal starten {GLib.markup_escape_text(pfx)}') else: self.lbl_beamng.set_markup('BeamNG: nicht gefunden (AppID 284160 – bitte in Steam installieren)') self.lbl_prefix.set_markup('Prefix: –') pvs = find_proton_versions() self.cmb_proton.remove_all() if pvs: self.lbl_proton.set_markup(f'Proton: {len(pvs)} gefunden') 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('Proton: keins gefunden – bitte z.B. Proton 9 in Steam installieren') 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" 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()