using System.Diagnostics; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; namespace Prospect.Client.Config; // Cross-platform (Linux/Proton + Windows) helper to point a The Cycle: Frontier client at a // server. The Prospect agent reads the target PlayFab URL from `backend.txt` next to the game // executable, so switching servers = writing that one file. This tool also imports the server // certificate and launches the game (natively on Windows, guided on Linux/Proton). internal static class Program { private const string AppId = "868270"; // Presets use the public domains so the same target works for everyone (LAN or internet): // DNS resolves to the public IP for friends, and the unified TLS cert covers both hostnames. // On the same LAN, add a hosts entry (domain -> 192.168.1.136) to avoid hairpin NAT. private static readonly (string Key, string Label, string Url)[] Presets = { ("prod", "PROD", "https://tc.nfteam.ovh:8443"), ("preprod", "PREPROD", "https://rd-tc.nfteam.ovh:8444"), }; private static string SettingsPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ProspectSwitcher", "settings.json"); private static Settings _settings = new(); private static int Main(string[] args) { try { Console.OutputEncoding = Encoding.UTF8; } catch { /* some terminals reject it */ } LoadSettings(); return args.Length > 0 ? RunCli(args) : MenuLoop(); } // ---- Scriptable CLI ---------------------------------------------------- private static int RunCli(string[] args) { string? folder = null, target = null; for (var i = 0; i < args.Length; i++) { switch (args[i]) { case "--folder" when i + 1 < args.Length: folder = args[++i]; break; case "--set" when i + 1 < args.Length: target = args[++i]; break; case "-h" or "--help": PrintCliHelp(); return 0; } } if (folder != null) { _settings.GameFolder = folder; SaveSettings(); } if (target == null) { PrintCliHelp(); return folder != null ? 0 : 1; } var url = ResolveTarget(target); if (url == null) { Console.Error.WriteLine("Cible inconnue : " + target); return 1; } return WriteBackend(url) ? 0 : 1; } private static void PrintCliHelp() { Console.WriteLine("ProspectServerSwitcher — sélecteur de serveur The Cycle (multi-OS)"); Console.WriteLine(); Console.WriteLine(" (sans argument) menu interactif"); Console.WriteLine(" --folder définit le dossier du jeu (mémorisé)"); Console.WriteLine(" --set écrit backend.txt"); Console.WriteLine(); Console.WriteLine("Cibles : " + string.Join(", ", Presets.Select(p => p.Key)) + ", ou une URL https://…"); Console.WriteLine("Ex : ProspectServerSwitcher --folder \"…/Prospect/Binaries/Win64\" --set preprod-lan"); } private static string? ResolveTarget(string t) { foreach (var p in Presets) if (string.Equals(p.Key, t, StringComparison.OrdinalIgnoreCase)) return p.Url; if (t.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return t; return null; } // ---- Interactive menu -------------------------------------------------- private static int MenuLoop() { while (true) { Console.WriteLine(); Console.WriteLine("=== The Cycle — Sélecteur de serveur ==="); Console.WriteLine("Dossier du jeu : " + (string.IsNullOrEmpty(_settings.GameFolder) ? "(non défini)" : _settings.GameFolder)); Console.WriteLine("backend.txt : " + ReadCurrentBackend()); Console.WriteLine(); Console.WriteLine(" 1) Définir le dossier du jeu (Win64)"); for (var i = 0; i < Presets.Length; i++) Console.WriteLine($" {i + 2}) {Presets[i].Label,-24} {Presets[i].Url}"); var n = Presets.Length + 2; Console.WriteLine($" {n}) Serveur personnalisé…"); Console.WriteLine($" {n + 1}) Importer un certificat…"); Console.WriteLine($" {n + 2}) Lancer le jeu"); Console.WriteLine(" 0) Quitter"); Console.Write("> "); var raw = Console.ReadLine()?.Trim(); if (raw is null or "0") return 0; if (!int.TryParse(raw, out var choice)) { Console.WriteLine("Choix invalide."); continue; } if (choice == 1) SetFolder(); else if (choice >= 2 && choice < n) WriteBackend(Presets[choice - 2].Url); else if (choice == n) SetCustom(); else if (choice == n + 1) ImportCert(); else if (choice == n + 2) LaunchGame(); else Console.WriteLine("Choix invalide."); } } private static void SetFolder() { Console.Write("Chemin du dossier Win64 : "); var p = Console.ReadLine()?.Trim().Trim('"'); if (string.IsNullOrEmpty(p)) return; if (!Directory.Exists(p)) { Console.WriteLine("⚠ Dossier introuvable."); return; } _settings.GameFolder = p; SaveSettings(); Console.WriteLine("✔ Dossier mémorisé."); } private static void SetCustom() { Console.Write("URL du serveur (ex : https://mon-serveur:8443) : "); var u = Console.ReadLine()?.Trim(); if (!string.IsNullOrWhiteSpace(u)) WriteBackend(u); } // ---- backend.txt ------------------------------------------------------- private static bool WriteBackend(string url) { if (!EnsureFolder(out var dir)) return false; try { // No trailing newline: the agent reads the raw file and requires it to start with https://. File.WriteAllText(Path.Combine(dir, "backend.txt"), url, new UTF8Encoding(false)); Console.WriteLine($"✔ backend.txt → {url}"); Console.WriteLine(" (relance le jeu : backend.txt n'est lu qu'au démarrage du loader.)"); return true; } catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return false; } } private static string ReadCurrentBackend() { if (string.IsNullOrEmpty(_settings.GameFolder)) return "(dossier non défini)"; var f = Path.Combine(_settings.GameFolder, "backend.txt"); try { if (!File.Exists(f)) return "(absent)"; var v = File.ReadAllText(f).Trim(); var match = Presets.FirstOrDefault(p => p.Url == v); return match.Key != null ? $"{v} [{match.Label.Trim()}]" : v; } catch { return "(illisible)"; } } // ---- Certificate ------------------------------------------------------- private static void ImportCert() { // Prefer fetching the cert live from the server the client points at (backend.txt), so // it's always the current one; fall back to a .crt file. X509Certificate2? cert = null; var target = ReadBackendUrl(); if (target != null && TryFetchServerCert(target, out cert)) Console.WriteLine($"Certificat récupéré depuis {target}"); else { Console.Write("Chemin d'un certificat (.crt) : "); var p = Console.ReadLine()?.Trim().Trim('"'); if (string.IsNullOrEmpty(p) || !File.Exists(p)) { Console.WriteLine("⚠ Annulé."); return; } try { cert = new X509Certificate2(p); } catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return; } } if (cert == null) { Console.WriteLine("⚠ Aucun certificat."); return; } Console.WriteLine($" empreinte : {cert.Thumbprint}"); if (OperatingSystem.IsWindows()) ImportCertWindows(cert); else ImportCertProton(cert); } private static string? ReadBackendUrl() { if (string.IsNullOrEmpty(_settings.GameFolder)) return null; var f = Path.Combine(_settings.GameFolder, "backend.txt"); try { return File.Exists(f) ? File.ReadAllText(f).Trim() : null; } catch { return null; } } private static bool TryFetchServerCert(string url, out X509Certificate2? cert) { cert = null; try { var uri = new Uri(url); using var tcp = new System.Net.Sockets.TcpClient(); tcp.Connect(uri.Host, uri.Port <= 0 ? 443 : uri.Port); X509Certificate2? captured = null; using var ssl = new System.Net.Security.SslStream(tcp.GetStream(), false, (_, c, _, _) => { if (c != null) captured = new X509Certificate2(c.Export(X509ContentType.Cert)); return true; }); ssl.AuthenticateAsClient(uri.Host); cert = captured; return cert != null; } catch { return false; } } [System.Runtime.Versioning.SupportedOSPlatform("windows")] private static void ImportCertWindows(X509Certificate2 cert) { try { using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); store.Open(OpenFlags.ReadWrite); store.Add(cert); Console.WriteLine("✔ Certificat importé (Autorités racines de confiance, utilisateur)."); } catch (Exception ex) { Console.WriteLine("✖ Import : " + ex.Message); } } // Trust the cert inside the game's Proton/Wine prefix by writing the serialized cert blob // into the prefix Root store via `wine reg import` (the wine `certutil` tool is unreliable // under Proton). The prefix is chosen explicitly, so it works even though the client runs // as a non-Steam shortcut (whose appid isn't the game's 868270). private static void ImportCertProton(X509Certificate2 cert) { var wine = FindProtonWine(); if (wine == null) { Console.WriteLine("⚠ Aucun wine Proton trouvé (Steam/Proton installé ?)."); return; } var prefix = SelectPrefix(); if (prefix == null) return; // Wine serialized cert blob: SHA1-hash property (id 3) + encoded cert property (id 0x20). var der = cert.RawData; var sha1 = cert.GetCertHash(); byte[] blob; using (var ms = new MemoryStream()) { void Prop(uint id, byte[] data) { ms.Write(BitConverter.GetBytes(id)); ms.Write(BitConverter.GetBytes(1u)); ms.Write(BitConverter.GetBytes((uint)data.Length)); ms.Write(data); } Prop(3, sha1); Prop(0x20, der); blob = ms.ToArray(); } var thumb = Convert.ToHexString(sha1); var hex = string.Join(",", blob.Select(b => b.ToString("x2"))); var regFile = Path.Combine(Path.GetTempPath(), "prospect-cert.reg"); File.WriteAllText(regFile, "REGEDIT4\r\n\r\n" + $"[HKEY_LOCAL_MACHINE\\Software\\Microsoft\\SystemCertificates\\Root\\Certificates\\{thumb}]\r\n" + $"\"Blob\"=hex:{hex}\r\n"); Console.WriteLine("⚠ Ferme complètement le jeu avant de continuer (sinon le registre ne sera pas pris en compte)."); Console.Write("Appuie sur Entrée pour importer… "); Console.ReadLine(); var wineserver = Path.Combine(Path.GetDirectoryName(wine)!, "wineserver"); var regWinePath = "Z:" + regFile.Replace('/', '\\'); // Z: maps to / inside the prefix RunWine(wineserver, "-k", prefix); // stop any running wineserver (flush) RunWine(wine, $"reg import \"{regWinePath}\"", prefix); RunWine(wineserver, "-k", prefix); // flush the change to disk var sysreg = Path.Combine(prefix, "system.reg"); var ok = File.Exists(sysreg) && File.ReadAllText(sysreg).Contains(thumb, StringComparison.OrdinalIgnoreCase); Console.WriteLine(ok ? $"✔ Certificat importé dans le préfixe : {prefix}\n Relance le jeu." : "✖ Import non confirmé — assure-toi que le jeu est fermé et réessaie."); } private static void RunWine(string exe, string args, string prefix) { try { var psi = new ProcessStartInfo(exe, args) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, }; psi.Environment["WINEPREFIX"] = prefix; psi.Environment["WINEDEBUG"] = "-all"; using var p = Process.Start(psi); p?.WaitForExit(20000); } catch (Exception ex) { Console.WriteLine(" (wine) " + ex.Message); } } private static string? FindProtonWine() { foreach (var root in SteamRoots()) { var common = Path.Combine(root, "steamapps", "common"); if (!Directory.Exists(common)) continue; var candidates = Directory.GetDirectories(common, "Proton*") .Select(d => Path.Combine(d, "files", "bin", "wine")) .Where(File.Exists) .OrderByDescending(w => w.Contains("Experimental")) .ThenByDescending(File.GetLastWriteTimeUtc) .ToList(); if (candidates.Count > 0) return candidates[0]; } return null; } private static string? SelectPrefix() { if (!string.IsNullOrEmpty(_settings.WinePrefix) && Directory.Exists(_settings.WinePrefix)) return _settings.WinePrefix; var prefixes = new List(); foreach (var root in SteamRoots()) { var cd = Path.Combine(root, "steamapps", "compatdata"); if (!Directory.Exists(cd)) continue; foreach (var d in Directory.GetDirectories(cd)) { var pfx = Path.Combine(d, "pfx"); if (File.Exists(Path.Combine(pfx, "system.reg"))) prefixes.Add(Path.GetFullPath(pfx)); } } prefixes = prefixes.Distinct() .OrderByDescending(p => File.GetLastWriteTimeUtc(Path.Combine(p, "system.reg"))) .ToList(); if (prefixes.Count == 0) { Console.WriteLine("⚠ Aucun préfixe Proton trouvé."); return null; } Console.WriteLine("Préfixes Proton détectés (le plus récemment utilisé en premier = sûrement ton jeu) :"); for (var i = 0; i < prefixes.Count; i++) Console.WriteLine($" {i + 1}) {prefixes[i]}"); Console.Write("Numéro du préfixe (Entrée = 1) : "); var raw = Console.ReadLine()?.Trim(); var idx = string.IsNullOrEmpty(raw) ? 0 : (int.TryParse(raw, out var n) ? n - 1 : -1); if (idx < 0 || idx >= prefixes.Count) { Console.WriteLine("⚠ Choix invalide."); return null; } _settings.WinePrefix = prefixes[idx]; SaveSettings(); return _settings.WinePrefix; } private static IEnumerable SteamRoots() { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); yield return Path.Combine(home, ".steam", "steam"); yield return Path.Combine(home, ".local", "share", "Steam"); } // ---- Launch ------------------------------------------------------------ private static void LaunchGame() { if (OperatingSystem.IsWindows()) { if (!EnsureFolder(out var dir)) return; var loader = Path.Combine(dir, "Prospect.Client.Loader.exe"); if (!File.Exists(loader)) { Console.WriteLine("⚠ Prospect.Client.Loader.exe introuvable dans le dossier."); return; } try { Process.Start(new ProcessStartInfo(loader) { WorkingDirectory = dir, UseShellExecute = true }); Console.WriteLine("▶ Jeu lancé."); } catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); } return; } Console.WriteLine("Sous Linux/Proton, lance via Steam (le loader doit tourner dans le préfixe Proton) :"); Console.WriteLine(" steam steam://rungameid/" + AppId); } // ---- Helpers ----------------------------------------------------------- private static bool EnsureFolder(out string dir) { dir = _settings.GameFolder; if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) { Console.WriteLine("⚠ Définis d'abord le dossier du jeu (option 1)."); return false; } return true; } private static void LoadSettings() { try { if (File.Exists(SettingsPath)) _settings = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath)) ?? new Settings(); } catch { _settings = new Settings(); } } private static void SaveSettings() { try { Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!); File.WriteAllText(SettingsPath, JsonSerializer.Serialize(_settings)); } catch { /* best-effort persistence */ } } private sealed class Settings { public string GameFolder { get; set; } = ""; public string WinePrefix { get; set; } = ""; } }