diff --git a/SS14.Launcher.sln.DotSettings b/SS14.Launcher.sln.DotSettings
index 6c2ee26..238b512 100644
--- a/SS14.Launcher.sln.DotSettings
+++ b/SS14.Launcher.sln.DotSettings
@@ -3,6 +3,7 @@
OS
VM
True
+ True
True
True
True
\ No newline at end of file
diff --git a/SS14.Launcher/Assets/Locale/en-US/text.ftl b/SS14.Launcher/Assets/Locale/en-US/text.ftl
index e8fce43..869b075 100644
--- a/SS14.Launcher/Assets/Locale/en-US/text.ftl
+++ b/SS14.Launcher/Assets/Locale/en-US/text.ftl
@@ -234,6 +234,10 @@ region-short-south-america-west = SA West
## Strings for the "servers" tab
tab-servers-title = Servers
+tab-servers-byond-title = BYOND Servers
+tab-servers-byond-error-msg = BYOND not installed or found
+tab-servers-byond-error-desc = To connect to BYOND servers, please install BYOND from https://www.byond.com/download/ and ensure it is set as the default program for handling byond:// links.
+tab-servers-byond-error-link-text = Download BYOND
tab-servers-refresh = Refresh
filters = Filters ({ $filteredServers } / { $totalServers })
tab-servers-search-watermark = Search For Servers…
diff --git a/SS14.Launcher/Models/ServerStatus/ClassicServerListCache.cs b/SS14.Launcher/Models/ServerStatus/ClassicServerListCache.cs
new file mode 100644
index 0000000..47a3dc4
--- /dev/null
+++ b/SS14.Launcher/Models/ServerStatus/ClassicServerListCache.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Serilog;
+using Splat;
+using SS14.Launcher.Utility;
+
+namespace SS14.Launcher.Models.ServerStatus;
+
+public sealed class ClassicServerListCache
+{
+ private readonly HttpClient _http;
+ private readonly ObservableCollection _allServers = new();
+
+ public ReadOnlyObservableCollection AllServers { get; }
+
+ public ClassicServerListCache()
+ {
+ _http = Locator.Current.GetRequiredService();
+ AllServers = new ReadOnlyObservableCollection(_allServers);
+ }
+
+ public async Task Refresh()
+ {
+ try
+ {
+ var response = await _http.GetStringAsync("http://www.byond.com/games/exadv1/spacestation13?format=text");
+ var servers = ParseByondResponse(response);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ _allServers.Clear();
+ foreach (var server in servers)
+ {
+ _allServers.Add(server);
+ }
+ });
+ }
+ catch (Exception e)
+ {
+ Log.Error(e, "Failed to fetch Classic SS13 server list.");
+ }
+ }
+
+ private List ParseByondResponse(string response)
+ {
+ var list = new List();
+ using var reader = new StringReader(response);
+
+ string? line;
+ string? currentName = null;
+ string? currentUrl = null;
+ string? currentStatus = null;
+ int currentPlayers = 0;
+
+ // Simple state machine to parse the text format
+ // The format uses 'world/ID' blocks for servers.
+
+ bool inServerBlock = false;
+
+ while ((line = reader.ReadLine()) != null)
+ {
+ var trimmed = line.Trim();
+ if (string.IsNullOrWhiteSpace(trimmed)) continue;
+
+ if (trimmed.StartsWith("world/"))
+ {
+ // If we were parsing a server, save it
+ if (inServerBlock && currentUrl != null)
+ {
+ // Name might be missing, try to extract from status or use URL
+ var name = currentName ?? ExtractNameFromStatus(currentStatus) ?? "Unknown Server";
+ var roundTime = ExtractRoundTimeFromStatus(currentStatus);
+ list.Add(new ClassicServerStatusData(name, currentUrl, currentPlayers, CleanStatus(currentStatus, name) ?? "", roundTime ?? "In-Lobby"));
+ }
+
+ // Reset for new server
+ inServerBlock = true;
+ currentName = null;
+ currentUrl = null;
+ currentStatus = null;
+ currentPlayers = 0;
+ }
+ else if (inServerBlock)
+ {
+ if (trimmed.StartsWith("name ="))
+ {
+ currentName = ParseStringValue(trimmed);
+ }
+ else if (trimmed.StartsWith("url ="))
+ {
+ currentUrl = ParseStringValue(trimmed);
+ }
+ else if (trimmed.StartsWith("status ="))
+ {
+ currentStatus = ParseStringValue(trimmed);
+ }
+ else if (trimmed.StartsWith("players = list("))
+ {
+ // "players = list("Bob","Alice")"
+ // Just count the commas + 1, correcting for empty list "list()"
+ var content = trimmed.Substring("players = list(".Length);
+ if (content.EndsWith(")"))
+ {
+ content = content.Substring(0, content.Length - 1);
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ currentPlayers = 0;
+ }
+ else
+ {
+ // A simple Count(',') + 1 is risky if names contain commas, but usually they are quoted.
+ // However, parsing full CSV is safer but 'Splitting by ",' might be enough?
+ // Let's iterate and count quoted segments.
+ // Or simpler: Splitting by ',' is mostly fine for SS13 ckeys.
+ currentPlayers = content.Split(',').Length;
+ }
+ }
+ }
+ else if (trimmed.StartsWith("players ="))
+ {
+ // Fallback for simple number if ever used
+ var parts = trimmed.Split('=');
+ if (parts.Length > 1 && int.TryParse(parts[1].Trim(), out var p))
+ {
+ currentPlayers = p;
+ }
+ }
+ }
+ }
+
+ // Add the last one if exists
+ if (inServerBlock && currentUrl != null)
+ {
+ var name = currentName ?? ExtractNameFromStatus(currentStatus) ?? "Unknown Server";
+ var roundTime = ExtractRoundTimeFromStatus(currentStatus);
+ list.Add(new ClassicServerStatusData(name, currentUrl, currentPlayers, CleanStatus(currentStatus, name) ?? "", roundTime ?? "In-Lobby"));
+ }
+
+ return list;
+ }
+
+ private string? ExtractRoundTimeFromStatus(string? status)
+ {
+ if (string.IsNullOrEmpty(status)) return null;
+
+ // Try to match "Round time: 00:07" or similar
+ var match = System.Text.RegularExpressions.Regex.Match(status, @"Round\s+time:\s+(?:)?(\d{1,2}:\d{2})(?:)?", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ if (match.Success)
+ {
+ return match.Groups[1].Value;
+ }
+ return null;
+ }
+
+ private string? ExtractNameFromStatus(string? status)
+ {
+ if (string.IsNullOrEmpty(status)) return null;
+ // Usually starts with Name
+ var match = System.Text.RegularExpressions.Regex.Match(status, @"(.*?)");
+ if (match.Success)
+ {
+ var raw = match.Groups[1].Value;
+ // Remove nested tags if any
+ var clean = System.Text.RegularExpressions.Regex.Replace(raw, "<.*?>", String.Empty);
+ return System.Net.WebUtility.HtmlDecode(clean);
+ }
+ return null;
+ }
+
+ private string? CleanStatus(string? status, string? nameToRemove)
+ {
+ if (string.IsNullOrEmpty(status)) return null;
+
+ var s = status.Replace("
", "\n").Replace("
", "\n").Replace("
", "\n");
+ // Remove tags
+ s = System.Text.RegularExpressions.Regex.Replace(s, "<.*?>", String.Empty);
+
+ // Decode HTML
+ s = System.Net.WebUtility.HtmlDecode(s);
+
+ if (nameToRemove != null && s.StartsWith(nameToRemove))
+ {
+ s = s.Substring(nameToRemove.Length);
+ }
+
+ // Clean artifacts
+ char[] trims = { ' ', '\t', '\n', '\r', ']', ')', '-', '—', ':' };
+ s = s.TrimStart(trims).Trim();
+
+ // Reduce multiple newlines
+ s = System.Text.RegularExpressions.Regex.Replace(s, @"\n\s+", "\n");
+ s = System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n");
+
+ return s;
+ }
+
+ private string ParseStringValue(string line)
+ {
+ // format: key = "value"
+ var idx = line.IndexOf('"');
+ if (idx == -1) return string.Empty;
+ var lastIdx = line.LastIndexOf('"');
+ if (lastIdx <= idx) return string.Empty;
+
+ // Extract content inside quotes
+ var inner = line.Substring(idx + 1, lastIdx - idx - 1);
+
+ // Unescape BYOND/C string escapes
+ // \" -> "
+ // \n -> newline
+ // \\ -> \
+ // The most critical one is \n showing up as literal \n in UI.
+
+ // Simple manual unescape for common sequences
+ return inner.Replace("\\\"", "\"")
+ .Replace("\\n", "\n")
+ .Replace("\\\\", "\\")
+ .Replace("\\t", "\t");
+ }
+}
diff --git a/SS14.Launcher/Models/ServerStatus/ClassicServerStatusData.cs b/SS14.Launcher/Models/ServerStatus/ClassicServerStatusData.cs
new file mode 100644
index 0000000..51ac0b7
--- /dev/null
+++ b/SS14.Launcher/Models/ServerStatus/ClassicServerStatusData.cs
@@ -0,0 +1,10 @@
+namespace SS14.Launcher.Models.ServerStatus;
+
+public class ClassicServerStatusData(string name, string address, int playerCount, string status, string roundTime)
+{
+ public string Name { get; } = name;
+ public string Address { get; } = address;
+ public int PlayerCount { get; } = playerCount;
+ public string Status { get; } = status;
+ public string RoundTime { get; } = roundTime;
+}
diff --git a/SS14.Launcher/Program.cs b/SS14.Launcher/Program.cs
index 12d6c08..81787f0 100644
--- a/SS14.Launcher/Program.cs
+++ b/SS14.Launcher/Program.cs
@@ -234,6 +234,7 @@ private static AppBuilder BuildAvaloniaApp(DataManager cfg)
locator.RegisterConstant(authApi);
locator.RegisterConstant(hubApi);
locator.RegisterConstant(new ServerListCache());
+ locator.RegisterConstant(new ClassicServerListCache());
locator.RegisterConstant(loginManager);
locator.RegisterConstant(overrideAssets);
locator.RegisterConstant(launcherInfo);
diff --git a/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerEntryViewModel.cs b/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerEntryViewModel.cs
new file mode 100644
index 0000000..ce49746
--- /dev/null
+++ b/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerEntryViewModel.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Diagnostics;
+using ReactiveUI;
+using Serilog;
+using Splat;
+using SS14.Launcher.Localization;
+using SS14.Launcher.Models;
+using SS14.Launcher.Models.ServerStatus;
+using SS14.Launcher.Utility;
+
+namespace SS14.Launcher.ViewModels.MainWindowTabs;
+
+public class ClassicServerEntryViewModel : ViewModelBase
+{
+ private readonly MainWindowViewModel _mainWindow;
+ private readonly ClassicServerStatusData _server;
+
+ public string Name => _server.Name;
+ public string Address => _server.Address;
+ public string PlayerCount => _server.PlayerCount.ToString();
+ public string Status => _server.Status;
+ public string RoundTime => _server.RoundTime;
+
+ private bool _isExpanded;
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set => this.RaiseAndSetIfChanged(ref _isExpanded, value);
+ }
+
+ public ReactiveCommand ConnectCommand { get; }
+
+ public ClassicServerEntryViewModel(MainWindowViewModel mainWindow, ClassicServerStatusData server)
+ {
+ _mainWindow = mainWindow;
+ _server = server;
+
+ ConnectCommand = ReactiveCommand.Create(Connect);
+ }
+
+ private void Connect()
+ {
+ if (IsByondInstalled())
+ Helpers.OpenUri(new Uri(Address));
+ else
+ {
+ Log.Information("User attempted to connect to BYOND server but BYOND is not installed.");
+ // Set the MainWindowViewModel's CustomInfo to show the BYOND not installed message
+ // I didn't wanna make another dialog, reuse the generic thing :)
+ _mainWindow.CustomInfo = new LauncherInfoManager.CustomInfo()
+ {
+ Message = LocalizationManager.Instance.GetString("tab-servers-byond-error-msg"),
+ Description = LocalizationManager.Instance.GetString("tab-servers-byond-error-desc"),
+ LinkText = LocalizationManager.Instance.GetString("tab-servers-byond-error-link-text"),
+ Link = "https://www.byond.com/download/",
+ };
+ }
+ }
+
+ private bool IsByondInstalled()
+ {
+ #if WINDOWS
+ using var key = Registry.CurrentUser.OpenSubKey(@"Software\Dantom\BYOND");
+ return key != null;
+ #elif LINUX
+ // Ask xdg-mime if BYOND is registered
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "xdg-mime",
+ Arguments = "query default x-scheme-handler/byond",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ },
+ };
+ process.Start();
+ var output = process.StandardOutput.ReadToEnd();
+ process.WaitForExit();
+
+ return !string.IsNullOrWhiteSpace(output);
+ #elif MACOS
+ return true; // No idea, they might have it, might not
+ #endif
+ }
+}
diff --git a/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerListTabViewModel.cs b/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerListTabViewModel.cs
new file mode 100644
index 0000000..0895870
--- /dev/null
+++ b/SS14.Launcher/ViewModels/MainWindowTabs/ClassicServerListTabViewModel.cs
@@ -0,0 +1,78 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Linq;
+using Splat;
+using ReactiveUI;
+using SS14.Launcher.Models.ServerStatus;
+using SS14.Launcher.Localization;
+using SS14.Launcher.Utility;
+
+namespace SS14.Launcher.ViewModels.MainWindowTabs;
+
+public class ClassicServerListTabViewModel : MainWindowTabViewModel
+{
+ private readonly MainWindowViewModel _mainWindow;
+ private readonly ClassicServerListCache _cache;
+
+ private readonly LocalizationManager _loc = LocalizationManager.Instance;
+
+ public override string Name => _loc.GetString("tab-servers-byond-title");
+
+ private string? _searchString;
+
+ public string? SearchString
+ {
+ get => _searchString;
+ set
+ {
+ this.RaiseAndSetIfChanged(ref _searchString, value);
+ UpdateList();
+ }
+ }
+
+ public ObservableCollection AllServers { get; } = new();
+ public ReactiveCommand RefreshPressed { get; }
+
+ public ClassicServerListTabViewModel(MainWindowViewModel mainWindow)
+ {
+ _mainWindow = mainWindow;
+ _cache = Locator.Current.GetRequiredService();
+ RefreshPressed = ReactiveCommand.CreateFromTask(_cache.Refresh);
+
+ // Initial populate if any
+ UpdateList();
+
+ ((INotifyCollectionChanged)_cache.AllServers).CollectionChanged += OnServersChanged;
+ }
+
+ private void OnServersChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ UpdateList();
+ }
+
+ private void UpdateList()
+ {
+ AllServers.Clear();
+ // Filter then Sort by Players descending
+ var filtered = _cache.AllServers.Where(DoesSearchMatch);
+ var sorted = filtered.OrderByDescending(s => s.PlayerCount).ToList();
+
+ foreach (var s in sorted)
+ {
+ AllServers.Add(new ClassicServerEntryViewModel(_mainWindow, s));
+ }
+ }
+
+ private bool DoesSearchMatch(ClassicServerStatusData data)
+ {
+ if (string.IsNullOrWhiteSpace(_searchString))
+ return true;
+
+ return data.Name.Contains(_searchString, System.StringComparison.CurrentCultureIgnoreCase);
+ }
+
+ public override async void Selected()
+ {
+ await _cache.Refresh();
+ }
+}
diff --git a/SS14.Launcher/ViewModels/MainWindowTabs/HomePageViewModel.cs b/SS14.Launcher/ViewModels/MainWindowTabs/HomePageViewModel.cs
index c4c44c1..ab5a312 100644
--- a/SS14.Launcher/ViewModels/MainWindowTabs/HomePageViewModel.cs
+++ b/SS14.Launcher/ViewModels/MainWindowTabs/HomePageViewModel.cs
@@ -1,5 +1,4 @@
using System;
-using System.Linq;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
diff --git a/SS14.Launcher/ViewModels/MainWindowTabs/ServerEntryViewModel.cs b/SS14.Launcher/ViewModels/MainWindowTabs/ServerEntryViewModel.cs
index 904e944..450b547 100644
--- a/SS14.Launcher/ViewModels/MainWindowTabs/ServerEntryViewModel.cs
+++ b/SS14.Launcher/ViewModels/MainWindowTabs/ServerEntryViewModel.cs
@@ -3,7 +3,6 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.VisualTree;
-using DynamicData;
using Microsoft.Toolkit.Mvvm.ComponentModel;
using Microsoft.Toolkit.Mvvm.Messaging;
using SS14.Launcher.Api;
diff --git a/SS14.Launcher/ViewModels/MainWindowViewModel.cs b/SS14.Launcher/ViewModels/MainWindowViewModel.cs
index 9380753..044d277 100644
--- a/SS14.Launcher/ViewModels/MainWindowViewModel.cs
+++ b/SS14.Launcher/ViewModels/MainWindowViewModel.cs
@@ -39,6 +39,7 @@ public sealed class MainWindowViewModel : ViewModelBase, IErrorOverlayOwner
public HomePageViewModel HomeTab { get; }
public ServerListTabViewModel ServersTab { get; }
+ public ClassicServerListTabViewModel ClassicServersTab { get; }
public NewsTabViewModel NewsTab { get; }
public OptionsTabViewModel OptionsTab { get; }
@@ -51,6 +52,7 @@ public MainWindowViewModel()
_loc = LocalizationManager.Instance;
ServersTab = new ServerListTabViewModel(this);
+ ClassicServersTab = new ClassicServerListTabViewModel(this);
NewsTab = new NewsTabViewModel();
HomeTab = new HomePageViewModel(this);
OptionsTab = new OptionsTabViewModel();
@@ -58,6 +60,7 @@ public MainWindowViewModel()
var tabs = new List();
tabs.Add(HomeTab);
tabs.Add(ServersTab);
+ tabs.Add(ClassicServersTab);
// tabs.Add(NewsTab); //TODO: Make our own news site
tabs.Add(OptionsTab);
#if DEVELOPMENT
diff --git a/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml b/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml
new file mode 100644
index 0000000..6a72768
--- /dev/null
+++ b/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml.cs b/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml.cs
new file mode 100644
index 0000000..00691f3
--- /dev/null
+++ b/SS14.Launcher/Views/MainWindowTabs/ClassicServerEntryView.xaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace SS14.Launcher.Views.MainWindowTabs;
+
+public partial class ClassicServerEntryView : UserControl
+{
+ public ClassicServerEntryView()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml b/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml
new file mode 100644
index 0000000..b23fdb9
--- /dev/null
+++ b/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml.cs b/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml.cs
new file mode 100644
index 0000000..c1377b3
--- /dev/null
+++ b/SS14.Launcher/Views/MainWindowTabs/ClassicServerListTabView.xaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace SS14.Launcher.Views.MainWindowTabs;
+
+public partial class ClassicServerListTabView : UserControl
+{
+ public ClassicServerListTabView()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}