From a194bed0e63288150ebd97dc33e521d74d8dbdc1 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:55:16 -0400 Subject: [PATCH 01/24] add paths for MS VS 18/2026 --- scripts/build.ps1 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/build.ps1 b/scripts/build.ps1 index eedb8e00..4d6519b6 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -18,8 +18,13 @@ param ( ) # Functions -Function Find-MsBuild([int] $MaxVersion = 2022) +Function Find-MsBuild([int] $MaxVersion = 2026) { + $agent2026Path = "$Env:programfiles\Microsoft Visual Studio\18\BuildTools\MSBuild\Current\Bin\msbuild.exe" + $ent2026Path = "$Env:programfiles\Microsoft Visual Studio\18\Enterprise\MSBuild\Current\Bin\msbuild.exe" + $pro2026Path = "$Env:programfiles\Microsoft Visual Studio\18\Professional\MSBuild\Current\Bin\msbuild.exe" + $community2026Path = "$Env:programfiles\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\msbuild.exe" + $agent2022Path = "$Env:programfiles\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\msbuild.exe" $ent2022Path = "$Env:programfiles\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\msbuild.exe" $pro2022Path = "$Env:programfiles\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\msbuild.exe" @@ -39,6 +44,11 @@ Function Find-MsBuild([int] $MaxVersion = 2022) $fallback2013Path = "${Env:ProgramFiles(x86)}\MSBuild\12.0\Bin\MSBuild.exe" $fallbackPath = "C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe" + If ((2026 -le $MaxVersion) -And (Test-Path $agent2026Path)) { return $agent2026Path } + If ((2026 -le $MaxVersion) -And (Test-Path $ent2026Path)) { return $ent2026Path } + If ((2026 -le $MaxVersion) -And (Test-Path $pro2026Path)) { return $pro2026Path } + If ((2026 -le $MaxVersion) -And (Test-Path $community2026Path)) { return $community2026Path } + If ((2022 -le $MaxVersion) -And (Test-Path $agent2022Path)) { return $agent2022Path } If ((2022 -le $MaxVersion) -And (Test-Path $ent2022Path)) { return $ent2022Path } If ((2022 -le $MaxVersion) -And (Test-Path $pro2022Path)) { return $pro2022Path } From e8885f7f0582633814f2632aacf25829946ba46f Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:57:31 -0400 Subject: [PATCH 02/24] search for CS in normal directories and provide options for non-power users --- scripts/build.ps1 | 54 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 4d6519b6..5f8f6d7b 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -48,8 +48,8 @@ Function Find-MsBuild([int] $MaxVersion = 2026) If ((2026 -le $MaxVersion) -And (Test-Path $ent2026Path)) { return $ent2026Path } If ((2026 -le $MaxVersion) -And (Test-Path $pro2026Path)) { return $pro2026Path } If ((2026 -le $MaxVersion) -And (Test-Path $community2026Path)) { return $community2026Path } - - If ((2022 -le $MaxVersion) -And (Test-Path $agent2022Path)) { return $agent2022Path } + + If ((2022 -le $MaxVersion) -And (Test-Path $agent2022Path)) { return $agent2022Path } If ((2022 -le $MaxVersion) -And (Test-Path $ent2022Path)) { return $ent2022Path } If ((2022 -le $MaxVersion) -And (Test-Path $pro2022Path)) { return $pro2022Path } If ((2022 -le $MaxVersion) -And (Test-Path $community2022Path)) { return $community2022Path } @@ -116,12 +116,56 @@ If ($Update) Write-Host "[CSM Update Script] You have specified the -Update flag. The script will now update the local assemblies to the installed version under Cities: Skylines." Write-Host "[CSM Update Script] Please Note: This may break the mod if any major game changes have occured." - # Get the steam directory + # Get the game directory If ($GameDirectory -eq "") { - $GameDirectory = Read-Host "[CSM Update Script] Please enter your game folder directory. For example, for Windows, 'C:\Program Files\Steam\steamapps\common\Cities_Skylines' for Steam or 'C:\Program Files\Epic Games\CitiesSkylines' for Epic Games." + # Common default install locations + $DefaultPaths = @() + If ($IsMacOS) + { + $DefaultPaths += @{ Label = "Steam"; Path = "~/Library/Application Support/Steam/steamapps/common/Cities_Skylines" } + } + ElseIf ($IsLinux) + { + $DefaultPaths += @{ Label = "Steam"; Path = "~/.local/share/Steam/steamapps/common/Cities_Skylines" } + } + Else + { + $DefaultPaths += @{ Label = "Steam"; Path = "C:\Program Files (x86)\Steam\steamapps\common\Cities_Skylines" } + $DefaultPaths += @{ Label = "Epic Games"; Path = "C:\Program Files\Epic Games\CitiesSkylines" } + } + + # Only offer the ones that actually exist + $FoundPaths = @($DefaultPaths | Where-Object { Test-Path -Path $_.Path }) + + If ($FoundPaths.Count -gt 0) + { + Write-Host "[CSM Update Script] Found the following Cities: Skylines installation(s):" + For ($i = 0; $i -lt $FoundPaths.Count; $i++) + { + Write-Host "[CSM Update Script] $($i + 1)) $($FoundPaths[$i].Path) ($($FoundPaths[$i].Label))" + } + $OtherOption = $FoundPaths.Count + 1 + Write-Host "[CSM Update Script] $($OtherOption)) Enter a different directory" + + $Selection = Read-Host "[CSM Update Script] Please choose an option (1-$($OtherOption))" + + $SelectionIndex = 0 + If ([int]::TryParse($Selection, [ref]$SelectionIndex) -and $SelectionIndex -ge 1 -and $SelectionIndex -le $FoundPaths.Count) + { + $GameDirectory = $FoundPaths[$SelectionIndex - 1].Path + } + Else + { + $GameDirectory = Read-Host "[CSM Update Script] Please enter your game folder directory." + } + } + Else + { + $GameDirectory = Read-Host "[CSM Update Script] Please enter your game folder directory. For example, for Windows, 'C:\Program Files\Steam\steamapps\common\Cities_Skylines' for Steam or 'C:\Program Files\Epic Games\CitiesSkylines' for Epic Games." + } } - Else + Else { Write-Host "[CSM Update Script] Game directory: $($GameDirectory)" } From 13f97d941ad8f6f3a726b49bcbea43a105fe096e Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:06:51 -0400 Subject: [PATCH 03/24] add a powershell script for running the game server --- scripts/run-gs.ps1 | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 scripts/run-gs.ps1 diff --git a/scripts/run-gs.ps1 b/scripts/run-gs.ps1 new file mode 100644 index 00000000..6e30924e --- /dev/null +++ b/scripts/run-gs.ps1 @@ -0,0 +1,27 @@ +#!/bin/pwsh +######################################################################### +# This script runs the CSM.GS server locally (the NAT relay / public # +# server list service). Useful for testing hosting a server against a # +# self-hosted API server instead of the official one. # +######################################################################### + +# Params that can be passed in +param ( + [int]$Port = 4240, + [int]$HttpPort = 4241, + [string]$Configuration = "Debug" +) + +Write-Host "[CSM GS Script] Starting the CSM.GS server locally." +Write-Host "[CSM GS Script] UDP (NAT relay) port: $($Port)" +Write-Host "[CSM GS Script] HTTP (public server list) port: $($HttpPort)" +Write-Host "[CSM GS Script] Point your game's 'CSM API Server' setting at this machine, with matching ports, to use it." + +$env:GS_PORT = $Port +$env:GS_HTTP_PORT = $HttpPort + +# CSM.GS targets net7.0, which may not be installed if only a newer .NET is +# present. Roll forward to whatever major version is available. +$env:DOTNET_ROLL_FORWARD = "LatestMajor" + +dotnet run --project "$PSScriptRoot/../src/gs/CSM.GS.csproj" --configuration $Configuration From 791e357147e80e9217fca5b79a650220cc6a4f4f Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:07:36 -0400 Subject: [PATCH 04/24] API server might be hosted on a different port than 80 if it's not reverse proxied --- src/csm/Injections/MainMenuHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/csm/Injections/MainMenuHandler.cs b/src/csm/Injections/MainMenuHandler.cs index 8758053a..49ccbd91 100644 --- a/src/csm/Injections/MainMenuHandler.cs +++ b/src/csm/Injections/MainMenuHandler.cs @@ -37,7 +37,7 @@ public static void CheckForUpdate(bool alwaysShowInfo) { try { - string latest = new CSMWebClient().DownloadString($"http://{CSM.Settings.ApiServer}/api/version"); + string latest = new CSMWebClient().DownloadString($"http://{CSM.Settings.ApiServer}:{CSM.Settings.ApiServerHttpPort}/api/version"); latest = latest.Substring(1); string[] versionParts = latest.Split('.'); Version latestVersion = new Version(int.Parse(versionParts[0]), int.Parse(versionParts[1])); From 2e25e184e6a6f784e6f69c2bb2883c9668bfd555 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:07:53 -0400 Subject: [PATCH 05/24] add server name and public discoverability --- src/csm/Networking/Config/ServerConfig.cs | 19 +++++++++++++- src/csm/Networking/PublicServerListing.cs | 29 +++++++++++++++++++++ src/csm/Networking/Server.cs | 31 +++++++++++++---------- 3 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 src/csm/Networking/PublicServerListing.cs diff --git a/src/csm/Networking/Config/ServerConfig.cs b/src/csm/Networking/Config/ServerConfig.cs index 242fb97b..9dea7d7c 100644 --- a/src/csm/Networking/Config/ServerConfig.cs +++ b/src/csm/Networking/Config/ServerConfig.cs @@ -16,13 +16,18 @@ public class ServerConfig /// The optional password for this server. /// The maximum amount of players that can connect to the server. /// Whether automatic port forwarding (UPnP) should be enabled. - public ServerConfig(int port, string username, string password, int maxPlayers, bool enablePortForwarding) + /// The display name of the server, shown in the public server list. + /// Whether this server should be listed in the public server list. + public ServerConfig(int port, string username, string password, int maxPlayers, bool enablePortForwarding, + string name = "", bool listPublicly = false) { Port = port; Username = username; Password = password; MaxPlayers = maxPlayers; EnablePortForwarding = enablePortForwarding; + Name = name; + ListPublicly = listPublicly; } public ServerConfig() @@ -32,6 +37,8 @@ public ServerConfig() Password = ""; MaxPlayers = 0; EnablePortForwarding = true; + Name = ""; + ListPublicly = false; } /// @@ -59,5 +66,15 @@ public ServerConfig() /// Default is false for security reasons. /// public bool EnablePortForwarding; + + /// + /// Gets the display name of the server, shown in the public server list. + /// + public string Name; + + /// + /// Gets whether this server should be listed in the public server list. + /// + public bool ListPublicly; } } diff --git a/src/csm/Networking/PublicServerListing.cs b/src/csm/Networking/PublicServerListing.cs new file mode 100644 index 00000000..cd54d2bd --- /dev/null +++ b/src/csm/Networking/PublicServerListing.cs @@ -0,0 +1,29 @@ +using System; + +namespace CSM.Networking +{ + /// + /// A single entry in the public server list, as returned by the GS + /// GET /api/servers HTTP endpoint. Field names match the JSON response + /// exactly, since UnityEngine.JsonUtility matches by name. + /// + [Serializable] + public class PublicServerListing + { + public string Name; + public int CurrentPlayers; + public int MaxPlayers; + public bool HasPassword; + public string Address; + } + + /// + /// The response body of the GET /api/servers HTTP endpoint. The listings + /// are wrapped in an object since JsonUtility cannot deserialize root arrays. + /// + [Serializable] + public class PublicServerListResponse + { + public PublicServerListing[] Servers; + } +} diff --git a/src/csm/Networking/Server.cs b/src/csm/Networking/Server.cs index f7130ceb..fa7ad2e9 100644 --- a/src/csm/Networking/Server.cs +++ b/src/csm/Networking/Server.cs @@ -175,16 +175,30 @@ private void SetupHolePunching() _netServer.NatPunchModule.Init(natPunchListener); // Register on server + SendToApiServer(BuildRegistrationCommand()); + } + + /// + /// Builds a registration command containing the current connection and + /// public-listing information for this server. + /// + private ServerRegistrationCommand BuildRegistrationCommand() + { string localIp = NetUtils.GetLocalIp(LocalAddrType.IPv4); if (string.IsNullOrEmpty(localIp)) localIp = NetUtils.GetLocalIp(LocalAddrType.IPv6); - SendToApiServer(new ServerRegistrationCommand + return new ServerRegistrationCommand { LocalIp = localIp, LocalPort = Config.Port, - Token = ServerToken - }); + Token = ServerToken, + ServerName = Config.Name, + MaxPlayers = Config.MaxPlayers, + CurrentPlayers = ConnectedPlayers.Count, + HasPassword = !string.IsNullOrEmpty(Config.Password), + ListPublicly = Config.ListPublicly + }; } /// @@ -248,16 +262,7 @@ public void ProcessEvents() // Send keepalive to GS if (_keepAlive % (60 * 5) == 0) { - string localIp = NetUtils.GetLocalIp(LocalAddrType.IPv4); - if (string.IsNullOrEmpty(localIp)) - localIp = NetUtils.GetLocalIp(LocalAddrType.IPv6); - - SendToApiServer(new ServerRegistrationCommand - { - LocalIp = localIp, - LocalPort = Config.Port, - Token = ServerToken - }); + SendToApiServer(BuildRegistrationCommand()); } _keepAlive += 1; } From 2181fecaebcac7ac801aa4f420e31c3c5c674849 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:10:34 -0400 Subject: [PATCH 06/24] UI changes required for joining/finding public games --- src/csm/CSM.csproj | 2 + src/csm/Panels/BrowseServersPanel.cs | 197 +++++++++++++++++++++++++++ src/csm/Panels/HostGamePanel.cs | 63 ++++++--- src/csm/Panels/JoinGamePanel.cs | 34 ++++- src/csm/Panels/SettingsPanel.cs | 8 +- src/csm/Settings.cs | 7 + 6 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 src/csm/Panels/BrowseServersPanel.cs diff --git a/src/csm/CSM.csproj b/src/csm/CSM.csproj index 6cdcf716..55352f49 100644 --- a/src/csm/CSM.csproj +++ b/src/csm/CSM.csproj @@ -153,8 +153,10 @@ + + diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs new file mode 100644 index 00000000..b0231a88 --- /dev/null +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using ColossalFramework.Threading; +using ColossalFramework.UI; +using CSM.API; +using CSM.Helpers; +using CSM.Networking; +using CSM.Util; +using UnityEngine; + +namespace CSM.Panels +{ + public class BrowseServersPanel : UIPanel + { + private const int PollIntervalMs = 10000; + + private UIScrollablePanel _scrollablePanel; + private UILabel _statusLabel; + private UIButton _refreshButton; + private UIButton _closeButton; + + private readonly List _rowLabels = new List(); + private readonly List _joinButtons = new List(); + + private Thread _pollThread; + private volatile bool _polling; + + public override void Start() + { + AddUIComponent(typeof(UIDragHandle)); + + backgroundSprite = "GenericPanel"; + color = new Color32(110, 110, 110, 255); + + width = 450; + height = 500; + relativePosition = PanelManager.GetCenterPosition(this); + + this.CreateTitleLabel("Public Servers", new Vector2(140, -20)); + + _statusLabel = this.CreateLabel("Loading...", new Vector2(10, -60)); + _statusLabel.textAlignment = UIHorizontalAlignment.Center; + _statusLabel.width = 430; + + _scrollablePanel = AddUIComponent(); + _scrollablePanel.width = 410; + _scrollablePanel.height = 330; + _scrollablePanel.position = new Vector2(10, -85); + _scrollablePanel.clipChildren = true; + _scrollablePanel.name = "BrowseServersScrollablePanel"; + + this.AddScrollbar(_scrollablePanel); + + _refreshButton = this.CreateButton("Refresh", new Vector2(10, -430), 200); + _refreshButton.eventClick += (component, param) => FetchServerList(); + + _closeButton = this.CreateButton("Close", new Vector2(220, -430), 200); + _closeButton.eventClick += (component, param) => + { + isVisible = false; + }; + + eventVisibilityChanged += (component, visible) => + { + if (visible) + { + StartPolling(); + } + else + { + StopPolling(); + } + }; + } + + public override void OnDestroy() + { + StopPolling(); + base.OnDestroy(); + } + + private void StartPolling() + { + if (_pollThread != null) + return; + + _polling = true; + _pollThread = new Thread(PollLoop) { IsBackground = true }; + _pollThread.Start(); + } + + private void StopPolling() + { + _polling = false; + _pollThread = null; + } + + private void PollLoop() + { + while (_polling) + { + FetchServerList(); + + // Sleep in small increments so we react quickly when the panel is hidden. + for (int i = 0; i < PollIntervalMs / 100 && _polling; i++) + { + Thread.Sleep(100); + } + } + } + + private void FetchServerList() + { + try + { + string url = $"http://{CSM.Settings.ApiServer}:{CSM.Settings.ApiServerHttpPort}/api/servers"; + string json = new CSMWebClient().DownloadString(url); + PublicServerListResponse response = JsonUtility.FromJson(json); + PublicServerListing[] servers = response?.Servers ?? new PublicServerListing[0]; + + ThreadHelper.dispatcher.Dispatch(() => UpdateRows(servers)); + } + catch (Exception e) + { + Log.Warn($"Failed to fetch public server list: {e.Message}"); + ThreadHelper.dispatcher.Dispatch(() => + { + _statusLabel.text = "Failed to fetch the public server list."; + _statusLabel.isVisible = true; + }); + } + } + + private void UpdateRows(PublicServerListing[] servers) + { + foreach (UILabel label in _rowLabels) + { + Destroy(label); + } + + foreach (UIButton button in _joinButtons) + { + Destroy(button); + } + + _rowLabels.Clear(); + _joinButtons.Clear(); + + if (servers.Length == 0) + { + _statusLabel.text = "No public servers found."; + _statusLabel.isVisible = true; + return; + } + + _statusLabel.isVisible = false; + + int rowOffset = 0; + const int rowHeight = 40; + + foreach (PublicServerListing server in servers) + { + string lockIcon = server.HasPassword ? " [Locked]" : ""; + string name = string.IsNullOrEmpty(server.Name) ? "Unnamed Server" : server.Name; + string label = $"{name}{lockIcon}\n{server.CurrentPlayers}/{server.MaxPlayers} players"; + + UILabel serverLabel = _scrollablePanel.CreateLabel(label, new Vector2(5, rowOffset), 260, rowHeight); + serverLabel.textScale = 0.8f; + serverLabel.wordWrap = true; + _rowLabels.Add(serverLabel); + + UIButton joinButton = _scrollablePanel.CreateButton("Join", new Vector2(280, rowOffset), 100, rowHeight); + string address = server.Address; + joinButton.eventClick += (component, param) => OnJoinClick(address); + _joinButtons.Add(joinButton); + + rowOffset -= rowHeight; + } + } + + private void OnJoinClick(string address) + { + string[] parts = address.Split(':'); + if (parts.Length != 2 || !int.TryParse(parts[1], out int port)) + { + Log.Warn($"Received invalid server address from public server list: {address}"); + return; + } + + isVisible = false; + + JoinGamePanel joinPanel = PanelManager.ShowPanel(); + joinPanel.PrefillJoinAddress(parts[0], port); + } + } +} diff --git a/src/csm/Panels/HostGamePanel.cs b/src/csm/Panels/HostGamePanel.cs index 7b7dabc9..07027599 100644 --- a/src/csm/Panels/HostGamePanel.cs +++ b/src/csm/Panels/HostGamePanel.cs @@ -18,6 +18,8 @@ public class HostGamePanel : UIPanel private UITextField _portField; private UITextField _passwordField; private UITextField _usernameField; + private UITextField _serverNameField; + private UITextField _maxPlayersField; private UILabel _connectionStatus; private UILabel _localIp; @@ -30,6 +32,7 @@ public class HostGamePanel : UIPanel private UICheckBox _passwordBox; private UICheckBox _rememberBox; private UICheckBox _portForwardingBox; + private UICheckBox _listPubliclyBox; private ServerConfig _serverConfig; private bool _hasRemembered; @@ -45,59 +48,75 @@ public override void Start() color = new Color32(110, 110, 110, 250); width = 360; - height = 625; + height = 810; relativePosition = PanelManager.GetCenterPosition(this); // Title Label this.CreateTitleLabel("Host Server", new Vector2(120, -20)); + // Server Name label + this.CreateLabel("Server Name (Optional):", new Vector2(10, -65)); + // Server Name field + _serverNameField = this.CreateTextField(_serverConfig.Name, new Vector2(10, -90)); + // Port Label - this.CreateLabel("Port:", new Vector2(10, -65)); + this.CreateLabel("Port:", new Vector2(10, -145)); // Port field - _portField = this.CreateTextField(_serverConfig.Port.ToString(), new Vector2(10, -90)); + _portField = this.CreateTextField(_serverConfig.Port.ToString(), new Vector2(10, -170)); _portField.numericalOnly = true; + // Max Players label + this.CreateLabel("Max Players (0 = No Limit):", new Vector2(10, -225)); + // Max Players field + _maxPlayersField = this.CreateTextField(_serverConfig.MaxPlayers.ToString(), new Vector2(10, -250)); + _maxPlayersField.numericalOnly = true; + // Password label - this.CreateLabel("Password (Optional):", new Vector2(10, -145)); + this.CreateLabel("Password (Optional):", new Vector2(10, -305)); // Password checkbox - _passwordBox = this.CreateCheckBox("Show Password", new Vector2(10, -170)); + _passwordBox = this.CreateCheckBox("Show Password", new Vector2(10, -330)); _passwordBox.isChecked = false; // Password field - _passwordField = this.CreateTextField(_serverConfig.Password, new Vector2(10, -190)); + _passwordField = this.CreateTextField(_serverConfig.Password, new Vector2(10, -350)); _passwordField.isPasswordField = true; // Username label - this.CreateLabel("Username:", new Vector2(10, -245)); + this.CreateLabel("Username:", new Vector2(10, -405)); // Username field - _usernameField = this.CreateTextField(_serverConfig.Username, new Vector2(10, -270)); + _usernameField = this.CreateTextField(_serverConfig.Username, new Vector2(10, -430)); if (PlatformService.active && PlatformService.personaName != null && _serverConfig.Username == "") { _usernameField.text = PlatformService.personaName; } // Remember-Me box - _rememberBox = this.CreateCheckBox("Remember Me", new Vector2(10, -325)); + _rememberBox = this.CreateCheckBox("Remember Me", new Vector2(10, -485)); _rememberBox.isChecked = _hasRemembered; // Port Forwarding box (UPnP) - _portForwardingBox = this.CreateCheckBox("Enable Port Forwarding (UPnP)", new Vector2(10, -350)); + _portForwardingBox = this.CreateCheckBox("Enable Port Forwarding (UPnP)", new Vector2(10, -510)); _portForwardingBox.isChecked = _serverConfig.EnablePortForwarding; _portForwardingBox.tooltip = "Automatically forward ports via UPnP."; - _connectionStatus = this.CreateLabel("", new Vector2(10, -375)); + // List Publicly box + _listPubliclyBox = this.CreateCheckBox("List this server publicly", new Vector2(10, -535)); + _listPubliclyBox.isChecked = _serverConfig.ListPublicly; + _listPubliclyBox.tooltip = "Allow other players to discover this server in the public server list."; + + _connectionStatus = this.CreateLabel("", new Vector2(10, -560)); _connectionStatus.textAlignment = UIHorizontalAlignment.Center; _connectionStatus.textColor = new Color32(255, 0, 0, 255); // Create Local IP Label - _localIp = this.CreateLabel("", new Vector2(10, -405)); + _localIp = this.CreateLabel("", new Vector2(10, -590)); _localIp.textAlignment = UIHorizontalAlignment.Center; // Create External IP Label - _externalIp = this.CreateLabel("", new Vector2(10, -425)); + _externalIp = this.CreateLabel("", new Vector2(10, -610)); _externalIp.textAlignment = UIHorizontalAlignment.Center; // Create VPN IP Label - _vpnIp = this.CreateLabel("", new Vector2(10, -445)); + _vpnIp = this.CreateLabel("", new Vector2(10, -630)); _vpnIp.textAlignment = UIHorizontalAlignment.Center; // Request IP addresses async @@ -111,11 +130,11 @@ public override void Start() }; // Create Server Button - _createButton = this.CreateButton("Create Server", new Vector2(10, -490)); + _createButton = this.CreateButton("Create Server", new Vector2(10, -675)); _createButton.eventClick += OnCreateServerClick; // Close this dialog - _closeButton = this.CreateButton("Cancel", new Vector2(10, -560)); + _closeButton = this.CreateButton("Cancel", new Vector2(10, -745)); _closeButton.eventClick += (component, param) => { isVisible = false; @@ -171,7 +190,9 @@ private void RequestIPs() /// private void OnCreateServerClick(UIComponent uiComponent, UIMouseEventParameter eventParam) { - _serverConfig = new ServerConfig(Int32.Parse(_portField.text), _usernameField.text, _passwordField.text, 0, _portForwardingBox.isChecked); + int.TryParse(_maxPlayersField.text, out int maxPlayers); + _serverConfig = new ServerConfig(Int32.Parse(_portField.text), _usernameField.text, _passwordField.text, maxPlayers, _portForwardingBox.isChecked, + _serverNameField.text, _listPubliclyBox.isChecked); ConfigData.Save(ref _serverConfig, ConfigData.ServerFile, _rememberBox.isChecked); _connectionStatus.textColor = new Color32(255, 255, 0, 255); @@ -193,6 +214,14 @@ private void OnCreateServerClick(UIComponent uiComponent, UIMouseEventParameter return; } + // Max players must be a non-negative number + if (!string.IsNullOrEmpty(_maxPlayersField.text) && (!int.TryParse(_maxPlayersField.text, out int _) || maxPlayers < 0)) + { + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + _connectionStatus.text = "Max players must be a non-negative number"; + return; + } + // Username must be filled in if (string.IsNullOrEmpty(_usernameField.text)) { diff --git a/src/csm/Panels/JoinGamePanel.cs b/src/csm/Panels/JoinGamePanel.cs index 5df5a091..29904d19 100644 --- a/src/csm/Panels/JoinGamePanel.cs +++ b/src/csm/Panels/JoinGamePanel.cs @@ -23,6 +23,7 @@ public class JoinGamePanel : UIPanel private UIButton _connectButton; private UIButton _closeButton; private UIButton _troubleshootingButton; + private UIButton _browseServersButton; private UICheckBox _passwordBox; private UICheckBox _rememberBox; @@ -46,7 +47,7 @@ public override void Start() color = new Color32(110, 110, 110, 255); width = 360; - height = 585; + height = 620; relativePosition = PanelManager.GetCenterPosition(this); // Title Label @@ -106,6 +107,14 @@ public override void Start() MultiplayerManager.Instance.CurrentClient.StopMainMenuEventProcessor(); }; + // Browse public servers + _browseServersButton = this.CreateButton("Browse Public Servers", new Vector2(10, -580)); + _browseServersButton.eventClick += (component, param) => + { + isVisible = false; + PanelManager.ShowPanel(); + }; + _connectionStatus = this.CreateLabel("", new Vector2(10, -420)); _connectionStatus.textAlignment = UIHorizontalAlignment.Center; _connectionStatus.textColor = new Color32(255, 0, 0, 255); @@ -246,6 +255,29 @@ void Action() } } + /// + /// Prefills the IP address and port fields, for example when joining + /// a server picked from the public server browser. + /// + public void PrefillJoinAddress(string ip, int port) + { + void Action() + { + _ipAddressField.text = ip; + _portField.text = port.ToString(); + _connectionStatus.text = ""; + } + + if (_connectionStatus != null) + { + Action(); + } + else + { + _onStarted = Action; + } + } + public void SetConnecting() { void Action() diff --git a/src/csm/Panels/SettingsPanel.cs b/src/csm/Panels/SettingsPanel.cs index 437adbdd..da9cfc4f 100644 --- a/src/csm/Panels/SettingsPanel.cs +++ b/src/csm/Panels/SettingsPanel.cs @@ -51,7 +51,7 @@ public static void Build(UIHelperBase helper, Settings settings) { try { - new CSMWebClient().DownloadString($"http://{url}/api/version"); + new CSMWebClient().DownloadString($"http://{url}:{settings.ApiServerHttpPort.value}/api/version"); settings.ApiServer.value = url; } catch (Exception) @@ -76,6 +76,12 @@ public static void Build(UIHelperBase helper, Settings settings) }); portInput.numericalOnly = true; + UITextField httpPortInput = (UITextField) advancedGroup.AddTextfield("API Server HTTP Port", settings.ApiServerHttpPort.value.ToString(), text => {}, port => + { + settings.ApiServerHttpPort.value = int.Parse(port); + }); + httpPortInput.numericalOnly = true; + UIHelperBase buttonsGroup = helper.AddGroup("Buttons"); buttonsGroup.AddButton("Show Release Notes", () => { diff --git a/src/csm/Settings.cs b/src/csm/Settings.cs index 74670bdb..9ee82d88 100644 --- a/src/csm/Settings.cs +++ b/src/csm/Settings.cs @@ -19,6 +19,7 @@ public Settings() private const string DefaultLastSeenReleaseNotes = "0.0"; private const string DefaultApiServer = "api.citiesskylinesmultiplayer.com"; private const int DefaultApiServerPort = 4240; + private const int DefaultApiServerHttpPort = 4241; public readonly SavedBool DebugLogging = new SavedBool(nameof(DebugLogging), SettingsFile, DefaultDebugLogging, true); @@ -46,5 +47,11 @@ public bool UseChirper public readonly SavedInt ApiServerPort = new SavedInt(nameof(ApiServerPort), SettingsFile, DefaultApiServerPort, true); + + /// + /// The port that the API server's public server list HTTP endpoint runs on. + /// + public readonly SavedInt ApiServerHttpPort = + new SavedInt(nameof(ApiServerHttpPort), SettingsFile, DefaultApiServerHttpPort, true); } } From 7dd005a3ec8ff46799fcc5df20f582848a282e73 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:11:36 -0400 Subject: [PATCH 07/24] upgrade game server to include HTTP API and expose 4240 and 4241 --- src/gs/CSM.GS.csproj | 2 +- .../ApiServer/ServerRegistrationCommand.cs | 30 ++++++++ .../ApiServer/ServerRegistrationHandler.cs | 3 +- src/gs/Dockerfile | 4 +- src/gs/Http/PublicServerListing.cs | 33 +++++++++ src/gs/Program.cs | 70 ++++++++++++++----- src/gs/Server.cs | 34 ++++++++- src/gs/WorkerService.cs | 29 +++++++- 8 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 src/gs/Http/PublicServerListing.cs diff --git a/src/gs/CSM.GS.csproj b/src/gs/CSM.GS.csproj index bb8d9d29..3e8ec9a5 100644 --- a/src/gs/CSM.GS.csproj +++ b/src/gs/CSM.GS.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/src/gs/Commands/Data/ApiServer/ServerRegistrationCommand.cs b/src/gs/Commands/Data/ApiServer/ServerRegistrationCommand.cs index 69a2f342..ea7f8d86 100644 --- a/src/gs/Commands/Data/ApiServer/ServerRegistrationCommand.cs +++ b/src/gs/Commands/Data/ApiServer/ServerRegistrationCommand.cs @@ -27,5 +27,35 @@ public class ServerRegistrationCommand : ApiCommandBase /// [ProtoMember(3)] public int LocalPort { get; set; } + + /// + /// The display name of the server, shown in the public server list. + /// + [ProtoMember(4)] + public string ServerName { get; set; } + + /// + /// The maximum amount of players that can connect to this server. + /// + [ProtoMember(5)] + public int MaxPlayers { get; set; } + + /// + /// The current amount of connected players. + /// + [ProtoMember(6)] + public int CurrentPlayers { get; set; } + + /// + /// Whether a password is required to join this server. + /// + [ProtoMember(7)] + public bool HasPassword { get; set; } + + /// + /// Whether this server should be listed in the public server list. + /// + [ProtoMember(8)] + public bool ListPublicly { get; set; } } } diff --git a/src/gs/Commands/Handler/ApiServer/ServerRegistrationHandler.cs b/src/gs/Commands/Handler/ApiServer/ServerRegistrationHandler.cs index f5915d36..9285fc2e 100644 --- a/src/gs/Commands/Handler/ApiServer/ServerRegistrationHandler.cs +++ b/src/gs/Commands/Handler/ApiServer/ServerRegistrationHandler.cs @@ -7,7 +7,8 @@ public class ServerRegistrationHandler : ApiCommandHandler + /// A single entry in the public server list, as returned by the + /// GET /api/servers HTTP endpoint. Only contains information safe + /// to expose publicly (no token, no internal/external IP distinction). + /// + public class PublicServerListing + { + public string Name { get; set; } + + public int CurrentPlayers { get; set; } + + public int MaxPlayers { get; set; } + + public bool HasPassword { get; set; } + + /// + /// The address (ip:port) that a client should connect to in order to join. + /// + public string Address { get; set; } + } + + /// + /// The response body of the GET /api/servers HTTP endpoint. The listings are + /// wrapped in an object (rather than returned as a bare JSON array) since Unity's + /// JsonUtility, used to parse this on the mod side, cannot deserialize root arrays. + /// + public class PublicServerListResponse + { + public PublicServerListing[] Servers { get; set; } + } +} diff --git a/src/gs/Program.cs b/src/gs/Program.cs index 294b2743..bbc17c17 100644 --- a/src/gs/Program.cs +++ b/src/gs/Program.cs @@ -1,4 +1,9 @@ -using Microsoft.Extensions.Configuration; +using System.Linq; +using CSM.GS.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -7,26 +12,57 @@ namespace CSM.GS { /// /// This is the UDP hole punching server, the clients will connect - /// to this server to setup the correct ports + /// to this server to setup the correct ports. It also exposes an + /// HTTP endpoint listing servers that have opted in to public listing. /// public static class Program { - public static void Main(string[] args) => CreateHostBuilder(args).Build().Run(); + public static void Main(string[] args) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - private static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureAppConfiguration((_, config) => - { - config.AddJsonFile("settings.json", true, true); - config.AddEnvironmentVariables("GS_"); - config.AddCommandLine(args); - }) - .ConfigureLogging(logging => + builder.Configuration.AddJsonFile("settings.json", true, true); + builder.Configuration.AddEnvironmentVariables("GS_"); + builder.Configuration.AddCommandLine(args); + + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + // Keep PascalCase property names (disable the default camelCase policy) since + // the mod parses this JSON with Unity's JsonUtility, which matches field names + // case-sensitively against the C# field names (Name, CurrentPlayers, etc.). + builder.Services.Configure(options => + { + options.SerializerOptions.PropertyNamingPolicy = null; + }); + + int httpPort = int.TryParse(builder.Configuration.GetSection("HTTP_PORT").Value, out int val) ? val : 4241; + builder.WebHost.UseUrls($"http://0.0.0.0:{httpPort}"); + + WebApplication app = builder.Build(); + + // The mod pings this to check for updates and to validate a custom + // API server URL entered in settings (CSMWebClient just needs the + // request to succeed; the version itself isn't otherwise used here). + app.MapGet("/api/version", () => "v0.0"); + + app.MapGet("/api/servers", (WorkerService worker) => + new PublicServerListResponse { - logging.ClearProviders(); - logging.AddConsole(); - }) - .ConfigureServices((_, services) => - services.AddHostedService()); + Servers = worker.PublicServers.Select(server => new PublicServerListing + { + Name = server.ServerName, + CurrentPlayers = server.CurrentPlayers, + MaxPlayers = server.MaxPlayers, + HasPassword = server.HasPassword, + Address = $"{server.ExternalAddress.Address}:{server.ExternalAddress.Port}" + }).ToArray() + }); + + app.Run(); + } } } diff --git a/src/gs/Server.cs b/src/gs/Server.cs index 2a6d4655..db89771a 100644 --- a/src/gs/Server.cs +++ b/src/gs/Server.cs @@ -34,12 +34,44 @@ public class Server /// public DateTime LastPing { get; } - public Server(IPEndPoint internalAddress, IPEndPoint externalAddress, string token) + /// + /// The display name of the server, shown in the public server list. + /// + public string ServerName { get; } + + /// + /// The maximum amount of players that can connect to this server. + /// + public int MaxPlayers { get; } + + /// + /// The current amount of connected players. + /// + public int CurrentPlayers { get; } + + /// + /// Whether a password is required to join this server. + /// + public bool HasPassword { get; } + + /// + /// Whether this server should be listed in the public server list. + /// + public bool ListPublicly { get; } + + public Server(IPEndPoint internalAddress, IPEndPoint externalAddress, string token, + string serverName = null, int maxPlayers = 0, int currentPlayers = 0, + bool hasPassword = false, bool listPublicly = false) { LastPing = DateTime.Now; Token = token; InternalAddress = internalAddress; ExternalAddress = externalAddress; + ServerName = serverName; + MaxPlayers = maxPlayers; + CurrentPlayers = currentPlayers; + HasPassword = hasPassword; + ListPublicly = listPublicly; } } } diff --git a/src/gs/WorkerService.cs b/src/gs/WorkerService.cs index 7025bf14..26f40987 100644 --- a/src/gs/WorkerService.cs +++ b/src/gs/WorkerService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Reflection; using System.Threading; @@ -36,6 +37,18 @@ public class WorkerService : BackgroundService, INatPunchListener private readonly List _serversToRemove = new(); private readonly List _serversToRemoveByToken = new(); + /// + /// Servers that have opted in to being listed publicly. Keyed by token. + /// Read concurrently by the public server list HTTP endpoint, so this must + /// stay a thread-safe collection. + /// + private readonly ConcurrentDictionary _publicServers = new(); + + /// + /// Read-only snapshot of the currently publicly-listed, non-expired servers. + /// + public IReadOnlyCollection PublicServers => _publicServers.Values.ToArray(); + private readonly ConcurrentQueue _tasks = new(); private readonly Type _responsePacketType; @@ -127,6 +140,7 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) foreach (string token in _serversToRemoveByToken) { _gameServersByToken.Remove(token); + _publicServers.TryRemove(token, out _); } _serversToRemove.Clear(); @@ -206,16 +220,27 @@ public void OnNatIntroductionSuccess(IPEndPoint targetEndPoint, NatAddressType t // Ignore as we are the API server } - public void RegisterServer(IPEndPoint remoteEndPoint, IPEndPoint localEndPoint, string token) + public void RegisterServer(IPEndPoint remoteEndPoint, IPEndPoint localEndPoint, string token, + string serverName = null, int maxPlayers = 0, int currentPlayers = 0, + bool hasPassword = false, bool listPublicly = false) { if (!_gameServers.ContainsKey(remoteEndPoint.Address)) { _logger.LogInformation("[{ExternalAddress}] Registered Server: Internal Address={InternalAddress} Token={Token}", Anonymize(remoteEndPoint), Anonymize(localEndPoint), token); } // Always create new server entry, so that port numbers and the token are updated - Server server = new(localEndPoint, remoteEndPoint, token); + Server server = new(localEndPoint, remoteEndPoint, token, serverName, maxPlayers, currentPlayers, hasPassword, listPublicly); _gameServers[remoteEndPoint.Address] = server; _gameServersByToken[token] = server; + + if (listPublicly) + { + _publicServers[token] = server; + } + else + { + _publicServers.TryRemove(token, out _); + } } public void SendToServer(IPEndPoint server, ApiCommandBase command) From fc440655799578438977cfcbaabed52ab2065a10 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:58:07 -0400 Subject: [PATCH 08/24] send the keep-alive every 5s as computed by wallclock time --- src/csm/Networking/Server.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/csm/Networking/Server.cs b/src/csm/Networking/Server.cs index fa7ad2e9..b4738dde 100644 --- a/src/csm/Networking/Server.cs +++ b/src/csm/Networking/Server.cs @@ -31,8 +31,12 @@ public class Server // The server private readonly LiteNetLib.NetManager _netServer; - // Keep alive tick tracker - private int _keepAlive = 1; + // Keep alive timer + private DateTime _lastKeepAlive = DateTime.MinValue; + + // Must stay comfortably below the GS's KickTime (default 15s), since the + // registration entry expires if no heartbeat arrives before then. + private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(5); // Connected clients public Dictionary ConnectedPlayers { get; } = new Dictionary(); @@ -260,11 +264,11 @@ public void ProcessEvents() _netServer.NatPunchModule.PollEvents(); _netServer.PollEvents(); // Send keepalive to GS - if (_keepAlive % (60 * 5) == 0) + if (DateTime.Now.Subtract(_lastKeepAlive) >= KeepAliveInterval) { SendToApiServer(BuildRegistrationCommand()); + _lastKeepAlive = DateTime.Now; } - _keepAlive += 1; } /// From ad961de58ad6b3c62c315ec9bc62cdc833e11bd5 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:40:28 -0400 Subject: [PATCH 09/24] add a script that makes it more convenient to deploy built DLL files to non-Windows machine --- scripts/deploy.ps1 | 70 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 scripts/deploy.ps1 diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 new file mode 100644 index 00000000..3ca467fe --- /dev/null +++ b/scripts/deploy.ps1 @@ -0,0 +1,70 @@ +#!/bin/pwsh +######################################################################### +# This script copies the locally built CSM mod .dll files to a remote # +# machine's Cities: Skylines mod folder (e.g. deploying from a Windows # +# dev box to a Mac for testing). # +######################################################################### + +# Params that can be passed in +param ( + [string]$RemoteUser, + [string]$RemoteHost = "varuns-macbook-pro", + [string]$RemoteOS = "mac", + [string]$SourceDirectory = "..\src\csm\bin\Release", + [string]$RemoteModDirectory = "Default" +) + +if ([string]::IsNullOrEmpty($RemoteUser)) +{ + Write-Host "[CSM Deploy Script] You must specify -RemoteUser (the SSH username on $($RemoteHost))." + exit 1 +} + +If ($RemoteModDirectory -eq "Default") +{ + Switch ($RemoteOS.ToLower()) + { + "mac" { $RemoteModDirectory = "~/Library/Application Support/Colossal Order/Cities_Skylines/Addons/Mods/CSM" } + "macos" { $RemoteModDirectory = "~/Library/Application Support/Colossal Order/Cities_Skylines/Addons/Mods/CSM" } + "linux" { $RemoteModDirectory = "~/.local/share/Colossal Order/Cities_Skylines/Addons/Mods/CSM" } + default + { + Write-Host "[CSM Deploy Script] Unknown -RemoteOS '$($RemoteOS)'. Expected 'mac' or 'linux', or pass -RemoteModDirectory explicitly." + exit 1 + } + } +} + +$RemoteTarget = "$($RemoteUser)@$($RemoteHost)" + +Write-Host "[CSM Deploy Script] Source directory: $($SourceDirectory)" +Write-Host "[CSM Deploy Script] Remote target: $($RemoteTarget)" +Write-Host "[CSM Deploy Script] Remote mod directory: $($RemoteModDirectory)" + +# Make sure there is something to deploy +$Dlls = Get-ChildItem -Path $SourceDirectory -Filter "*.dll" -ErrorAction SilentlyContinue +If (-not $Dlls) +{ + Write-Host "[CSM Deploy Script] No .dll files found in $($SourceDirectory). Did you run build.ps1 -Build first?" + exit 1 +} + +# Make sure the remote mod directory exists +Write-Host "[CSM Deploy Script] Ensuring remote mod directory exists..." +ssh $RemoteTarget "mkdir -p '$($RemoteModDirectory)'" +If ($LASTEXITCODE -ne 0) +{ + Write-Host "[CSM Deploy Script] Failed to create/verify the remote mod directory. Check SSH connectivity to $($RemoteTarget)." + exit $LASTEXITCODE +} + +# Copy the dll files across +Write-Host "[CSM Deploy Script] Copying $($Dlls.Count) file(s)..." +scp "$($SourceDirectory)\*.dll" "$($RemoteTarget):$($RemoteModDirectory)/" +If ($LASTEXITCODE -ne 0) +{ + Write-Host "[CSM Deploy Script] Deploy failed!" + exit $LASTEXITCODE +} + +Write-Host "[CSM Deploy Script] Done! Files deployed to $($RemoteTarget):$($RemoteModDirectory)" From 6f087bb4e15b302cfed1cdb31a7b8314b3921c26 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:45:56 -0400 Subject: [PATCH 10/24] debug loggin for the parser --- src/csm/Networking/PublicServerListing.cs | 5 ++++- src/csm/Panels/BrowseServersPanel.cs | 24 +++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/csm/Networking/PublicServerListing.cs b/src/csm/Networking/PublicServerListing.cs index cd54d2bd..40070d83 100644 --- a/src/csm/Networking/PublicServerListing.cs +++ b/src/csm/Networking/PublicServerListing.cs @@ -6,9 +6,12 @@ namespace CSM.Networking /// A single entry in the public server list, as returned by the GS /// GET /api/servers HTTP endpoint. Field names match the JSON response /// exactly, since UnityEngine.JsonUtility matches by name. + /// Deliberately a struct, not a class: this Unity engine version's + /// JsonUtility fails to populate arrays of class-typed elements, but + /// arrays of structs deserialize correctly. /// [Serializable] - public class PublicServerListing + public struct PublicServerListing { public string Name; public int CurrentPlayers; diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index b0231a88..02780f20 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -53,7 +53,7 @@ public override void Start() this.AddScrollbar(_scrollablePanel); _refreshButton = this.CreateButton("Refresh", new Vector2(10, -430), 200); - _refreshButton.eventClick += (component, param) => FetchServerList(); + _refreshButton.eventClick += (component, param) => new Thread(FetchServerList) { IsBackground = true }.Start(); _closeButton = this.CreateButton("Close", new Vector2(220, -430), 200); _closeButton.eventClick += (component, param) => @@ -115,11 +115,27 @@ private void FetchServerList() try { string url = $"http://{CSM.Settings.ApiServer}:{CSM.Settings.ApiServerHttpPort}/api/servers"; + Log.Info($"[BrowseServersPanel] Requesting public server list from: {url}"); + Log.Info($"[BrowseServersPanel] Fetch running on thread: {Thread.CurrentThread.ManagedThreadId}, IsBackground: {Thread.CurrentThread.IsBackground}"); + string json = new CSMWebClient().DownloadString(url); - PublicServerListResponse response = JsonUtility.FromJson(json); - PublicServerListing[] servers = response?.Servers ?? new PublicServerListing[0]; + Log.Info($"[BrowseServersPanel] Raw response: {json}"); + + // JsonUtility is a UnityEngine API and must only be called from the main + // thread (unlike DownloadString above, which is fine off-thread) - calling + // it here on a background thread silently fails to populate nested arrays. + ThreadHelper.dispatcher.Dispatch(() => + { + Log.Info($"[BrowseServersPanel] Parsing on thread: {Thread.CurrentThread.ManagedThreadId}"); - ThreadHelper.dispatcher.Dispatch(() => UpdateRows(servers)); + PublicServerListResponse response = JsonUtility.FromJson(json); + Log.Info($"[BrowseServersPanel] response == null: {response == null}, response.Servers == null: {response?.Servers == null}"); + + PublicServerListing[] servers = response?.Servers ?? new PublicServerListing[0]; + Log.Info($"[BrowseServersPanel] Parsed {servers.Length} server(s) from response."); + + UpdateRows(servers); + }); } catch (Exception e) { From 67bc06c78cd5f5258f00677833ada77aa30c4ed3 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:21:17 -0400 Subject: [PATCH 11/24] use custom Json parser for PublicServerListing because JsonUtility isn't playing nicely with custom arrays of classes --- src/csm/CSM.csproj | 1 + src/csm/Networking/PublicServerListing.cs | 17 +- src/csm/Panels/BrowseServersPanel.cs | 54 ++++-- src/csm/Util/MiniJson.cs | 192 ++++++++++++++++++++++ 4 files changed, 235 insertions(+), 29 deletions(-) create mode 100644 src/csm/Util/MiniJson.cs diff --git a/src/csm/CSM.csproj b/src/csm/CSM.csproj index 55352f49..5a75ec2b 100644 --- a/src/csm/CSM.csproj +++ b/src/csm/CSM.csproj @@ -172,6 +172,7 @@ + diff --git a/src/csm/Networking/PublicServerListing.cs b/src/csm/Networking/PublicServerListing.cs index 40070d83..9a2f7fb7 100644 --- a/src/csm/Networking/PublicServerListing.cs +++ b/src/csm/Networking/PublicServerListing.cs @@ -1,16 +1,13 @@ -using System; - namespace CSM.Networking { /// /// A single entry in the public server list, as returned by the GS - /// GET /api/servers HTTP endpoint. Field names match the JSON response - /// exactly, since UnityEngine.JsonUtility matches by name. - /// Deliberately a struct, not a class: this Unity engine version's - /// JsonUtility fails to populate arrays of class-typed elements, but - /// arrays of structs deserialize correctly. + /// GET /api/servers HTTP endpoint. Parsed by CSM.Util.MiniJson, since + /// both UnityEngine.JsonUtility (fails to populate arrays of custom + /// types, class or struct) and Newtonsoft.Json (missing + /// System.Runtime.Serialization in the game's Mono runtime) are + /// unusable for this shape in this environment. /// - [Serializable] public struct PublicServerListing { public string Name; @@ -21,10 +18,8 @@ public struct PublicServerListing } /// - /// The response body of the GET /api/servers HTTP endpoint. The listings - /// are wrapped in an object since JsonUtility cannot deserialize root arrays. + /// The response body of the GET /api/servers HTTP endpoint. /// - [Serializable] public class PublicServerListResponse { public PublicServerListing[] Servers; diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index 02780f20..ed4b0e67 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -115,27 +115,16 @@ private void FetchServerList() try { string url = $"http://{CSM.Settings.ApiServer}:{CSM.Settings.ApiServerHttpPort}/api/servers"; - Log.Info($"[BrowseServersPanel] Requesting public server list from: {url}"); - Log.Info($"[BrowseServersPanel] Fetch running on thread: {Thread.CurrentThread.ManagedThreadId}, IsBackground: {Thread.CurrentThread.IsBackground}"); - string json = new CSMWebClient().DownloadString(url); - Log.Info($"[BrowseServersPanel] Raw response: {json}"); - - // JsonUtility is a UnityEngine API and must only be called from the main - // thread (unlike DownloadString above, which is fine off-thread) - calling - // it here on a background thread silently fails to populate nested arrays. - ThreadHelper.dispatcher.Dispatch(() => - { - Log.Info($"[BrowseServersPanel] Parsing on thread: {Thread.CurrentThread.ManagedThreadId}"); - - PublicServerListResponse response = JsonUtility.FromJson(json); - Log.Info($"[BrowseServersPanel] response == null: {response == null}, response.Servers == null: {response?.Servers == null}"); - PublicServerListing[] servers = response?.Servers ?? new PublicServerListing[0]; - Log.Info($"[BrowseServersPanel] Parsed {servers.Length} server(s) from response."); + // Parsed with MiniJson (not UnityEngine.JsonUtility or Newtonsoft.Json - see + // MiniJson.cs for why neither works for this shape in this environment). + // MiniJson is plain managed code, so unlike JsonUtility it's safe to parse + // off the main thread here; only the resulting UI update needs to be + // marshaled back via Dispatch. + PublicServerListing[] servers = ParseServers(json); - UpdateRows(servers); - }); + ThreadHelper.dispatcher.Dispatch(() => UpdateRows(servers)); } catch (Exception e) { @@ -148,6 +137,35 @@ private void FetchServerList() } } + private static PublicServerListing[] ParseServers(string json) + { + List servers = new List(); + + if (!(MiniJson.Parse(json) is Dictionary root) || + !root.TryGetValue("Servers", out object serversObj) || + !(serversObj is List serverList)) + { + return servers.ToArray(); + } + + foreach (object item in serverList) + { + if (!(item is Dictionary entry)) + continue; + + servers.Add(new PublicServerListing + { + Name = entry.TryGetValue("Name", out object name) ? (string)name : "", + CurrentPlayers = entry.TryGetValue("CurrentPlayers", out object cur) ? Convert.ToInt32(cur) : 0, + MaxPlayers = entry.TryGetValue("MaxPlayers", out object max) ? Convert.ToInt32(max) : 0, + HasPassword = entry.TryGetValue("HasPassword", out object pass) && (bool)pass, + Address = entry.TryGetValue("Address", out object addr) ? (string)addr : "" + }); + } + + return servers.ToArray(); + } + private void UpdateRows(PublicServerListing[] servers) { foreach (UILabel label in _rowLabels) diff --git a/src/csm/Util/MiniJson.cs b/src/csm/Util/MiniJson.cs new file mode 100644 index 00000000..3d62b0ee --- /dev/null +++ b/src/csm/Util/MiniJson.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace CSM.Util +{ + /// + /// A small, dependency-free JSON parser. Exists because, in this Unity/Mono + /// environment, UnityEngine.JsonUtility fails to deserialize arrays of custom + /// types (class or struct) and Newtonsoft.Json's default contract resolver + /// depends on System.Runtime.Serialization, which isn't part of the game's + /// bundled Mono runtime. Parses into plain object graphs: Dictionary<string, object> + /// for objects, List<object> for arrays, string, double, bool, or null for scalars. + /// + public static class MiniJson + { + public static object Parse(string json) + { + int index = 0; + object result = ParseValue(json, ref index); + return result; + } + + private static object ParseValue(string json, ref int index) + { + SkipWhitespace(json, ref index); + + char c = json[index]; + switch (c) + { + case '{': + return ParseObject(json, ref index); + case '[': + return ParseArray(json, ref index); + case '"': + return ParseString(json, ref index); + case 't': + case 'f': + return ParseBool(json, ref index); + case 'n': + index += 4; // "null" + return null; + default: + return ParseNumber(json, ref index); + } + } + + private static Dictionary ParseObject(string json, ref int index) + { + Dictionary result = new Dictionary(); + + index++; // consume '{' + SkipWhitespace(json, ref index); + + if (json[index] == '}') + { + index++; + return result; + } + + while (true) + { + SkipWhitespace(json, ref index); + string key = ParseString(json, ref index); + + SkipWhitespace(json, ref index); + index++; // consume ':' + + object value = ParseValue(json, ref index); + result[key] = value; + + SkipWhitespace(json, ref index); + if (json[index] == ',') + { + index++; + continue; + } + + index++; // consume '}' + break; + } + + return result; + } + + private static List ParseArray(string json, ref int index) + { + List result = new List(); + + index++; // consume '[' + SkipWhitespace(json, ref index); + + if (json[index] == ']') + { + index++; + return result; + } + + while (true) + { + object value = ParseValue(json, ref index); + result.Add(value); + + SkipWhitespace(json, ref index); + if (json[index] == ',') + { + index++; + continue; + } + + index++; // consume ']' + break; + } + + return result; + } + + private static string ParseString(string json, ref int index) + { + StringBuilder sb = new StringBuilder(); + + index++; // consume opening '"' + while (json[index] != '"') + { + char c = json[index]; + if (c == '\\') + { + index++; + char escaped = json[index]; + switch (escaped) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case '/': sb.Append('/'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'u': + string hex = json.Substring(index + 1, 4); + sb.Append((char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + index += 4; + break; + } + } + else + { + sb.Append(c); + } + + index++; + } + + index++; // consume closing '"' + return sb.ToString(); + } + + private static bool ParseBool(string json, ref int index) + { + if (json[index] == 't') + { + index += 4; // "true" + return true; + } + + index += 5; // "false" + return false; + } + + private static double ParseNumber(string json, ref int index) + { + int start = index; + while (index < json.Length && (char.IsDigit(json[index]) || json[index] == '-' || json[index] == '+' || + json[index] == '.' || json[index] == 'e' || json[index] == 'E')) + { + index++; + } + + return double.Parse(json.Substring(start, index - start), CultureInfo.InvariantCulture); + } + + private static void SkipWhitespace(string json, ref int index) + { + while (index < json.Length && char.IsWhiteSpace(json[index])) + { + index++; + } + } + } +} From 1f4b71f9f653c291c3c0d9973d352f4a8b7b8aef Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:29:35 -0400 Subject: [PATCH 12/24] display message to show refresh list status in case things take a couple seconds --- src/csm/Panels/BrowseServersPanel.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index ed4b0e67..5fc5d244 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -114,6 +114,11 @@ private void FetchServerList() { try { + ThreadHelper.dispatcher.Dispatch(() => + { + _statusLabel.text = "Fetching server list..."; + _statusLabel.isVisible = true; + }); string url = $"http://{CSM.Settings.ApiServer}:{CSM.Settings.ApiServerHttpPort}/api/servers"; string json = new CSMWebClient().DownloadString(url); From f8fe25ced464af277034f19d787d7ecaa01d35c7 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:29:43 -0400 Subject: [PATCH 13/24] remove vestigial macos --- scripts/deploy.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 3ca467fe..6b43cc91 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -25,7 +25,6 @@ If ($RemoteModDirectory -eq "Default") Switch ($RemoteOS.ToLower()) { "mac" { $RemoteModDirectory = "~/Library/Application Support/Colossal Order/Cities_Skylines/Addons/Mods/CSM" } - "macos" { $RemoteModDirectory = "~/Library/Application Support/Colossal Order/Cities_Skylines/Addons/Mods/CSM" } "linux" { $RemoteModDirectory = "~/.local/share/Colossal Order/Cities_Skylines/Addons/Mods/CSM" } default { From 35d0b3086e3cf8ae7d47b3bcba7b160d4869db64 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:30:58 -0400 Subject: [PATCH 14/24] use 'Protected' instead of 'Locked' --- src/csm/Panels/BrowseServersPanel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index 5fc5d244..f98a05f1 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -200,7 +200,7 @@ private void UpdateRows(PublicServerListing[] servers) foreach (PublicServerListing server in servers) { - string lockIcon = server.HasPassword ? " [Locked]" : ""; + string lockIcon = server.HasPassword ? " [Protected]" : ""; string name = string.IsNullOrEmpty(server.Name) ? "Unnamed Server" : server.Name; string label = $"{name}{lockIcon}\n{server.CurrentPlayers}/{server.MaxPlayers} players"; From ce3c5af9a6d6f5f9a818b9613d03123e273331fa Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:37:27 -0400 Subject: [PATCH 15/24] include ServerToken as a unique ID for the Server - include the Address to since we use it to connect the two osts --- src/csm/Networking/PublicServerListing.cs | 1 + src/csm/Panels/BrowseServersPanel.cs | 3 ++- src/gs/Http/PublicServerListing.cs | 1 + src/gs/Program.cs | 4 +++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/csm/Networking/PublicServerListing.cs b/src/csm/Networking/PublicServerListing.cs index 9a2f7fb7..05c5a76a 100644 --- a/src/csm/Networking/PublicServerListing.cs +++ b/src/csm/Networking/PublicServerListing.cs @@ -15,6 +15,7 @@ public struct PublicServerListing public int MaxPlayers; public bool HasPassword; public string Address; + public string ServerToken; } /// diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index f98a05f1..0f0f0f16 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -164,7 +164,8 @@ private static PublicServerListing[] ParseServers(string json) CurrentPlayers = entry.TryGetValue("CurrentPlayers", out object cur) ? Convert.ToInt32(cur) : 0, MaxPlayers = entry.TryGetValue("MaxPlayers", out object max) ? Convert.ToInt32(max) : 0, HasPassword = entry.TryGetValue("HasPassword", out object pass) && (bool)pass, - Address = entry.TryGetValue("Address", out object addr) ? (string)addr : "" + Address = entry.TryGetValue("Address", out object addr) ? (string)addr : "", + ServerToken = entry.TryGetValue("ServerToken", out object tok) ? (string)tok : "", }); } diff --git a/src/gs/Http/PublicServerListing.cs b/src/gs/Http/PublicServerListing.cs index b289310a..231da4c4 100644 --- a/src/gs/Http/PublicServerListing.cs +++ b/src/gs/Http/PublicServerListing.cs @@ -19,6 +19,7 @@ public class PublicServerListing /// The address (ip:port) that a client should connect to in order to join. /// public string Address { get; set; } + public string ServerToken { get; set; } } /// diff --git a/src/gs/Program.cs b/src/gs/Program.cs index bbc17c17..ec3425ae 100644 --- a/src/gs/Program.cs +++ b/src/gs/Program.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Security.Principal; using CSM.GS.Http; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -58,7 +59,8 @@ public static void Main(string[] args) CurrentPlayers = server.CurrentPlayers, MaxPlayers = server.MaxPlayers, HasPassword = server.HasPassword, - Address = $"{server.ExternalAddress.Address}:{server.ExternalAddress.Port}" + Address = $"{server.ExternalAddress.Address}:{server.ExternalAddress.Port}", + ServerToken = server.ServerToken, }).ToArray() }); From c3155aca91677f648d28ba7e68473952fec6ea9c Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:19:51 -0400 Subject: [PATCH 16/24] use protobufs and LiteNetLib instead of HTTP --- src/csm/CSM.csproj | 7 +- .../ApiServer/ServerListRequestHandler.cs | 12 ++ .../ApiServer/ServerListResultHandler.cs | 13 ++ src/csm/Networking/PublicServerListing.cs | 28 --- src/csm/Panels/BrowseServersPanel.cs | 179 ++++++++-------- src/csm/Settings.cs | 4 +- src/csm/Util/MiniJson.cs | 192 ------------------ .../Data/ApiServer/PublicServerEntry.cs | 38 ++++ .../ApiServer/ServerListRequestCommand.cs | 14 ++ .../Data/ApiServer/ServerListResultCommand.cs | 17 ++ .../ApiServer/ServerListRequestHandler.cs | 23 +++ .../ApiServer/ServerListResultHandler.cs | 12 ++ src/gs/Http/PublicServerListing.cs | 34 ---- src/gs/Program.cs | 34 +--- 14 files changed, 226 insertions(+), 381 deletions(-) create mode 100644 src/csm/GS/Commands/Handler/ApiServer/ServerListRequestHandler.cs create mode 100644 src/csm/GS/Commands/Handler/ApiServer/ServerListResultHandler.cs delete mode 100644 src/csm/Networking/PublicServerListing.cs delete mode 100644 src/csm/Util/MiniJson.cs create mode 100644 src/gs/Commands/Data/ApiServer/PublicServerEntry.cs create mode 100644 src/gs/Commands/Data/ApiServer/ServerListRequestCommand.cs create mode 100644 src/gs/Commands/Data/ApiServer/ServerListResultCommand.cs create mode 100644 src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs create mode 100644 src/gs/Commands/Handler/ApiServer/ServerListResultHandler.cs delete mode 100644 src/gs/Http/PublicServerListing.cs diff --git a/src/csm/CSM.csproj b/src/csm/CSM.csproj index 5a75ec2b..8391327b 100644 --- a/src/csm/CSM.csproj +++ b/src/csm/CSM.csproj @@ -107,10 +107,15 @@ + + + + + @@ -153,7 +158,6 @@ - @@ -172,7 +176,6 @@ - diff --git a/src/csm/GS/Commands/Handler/ApiServer/ServerListRequestHandler.cs b/src/csm/GS/Commands/Handler/ApiServer/ServerListRequestHandler.cs new file mode 100644 index 00000000..1d1d8757 --- /dev/null +++ b/src/csm/GS/Commands/Handler/ApiServer/ServerListRequestHandler.cs @@ -0,0 +1,12 @@ +using CSM.GS.Commands.Data.ApiServer; + +namespace CSM.GS.Commands.Handler.ApiServer +{ + public class ServerListRequestHandler : ApiCommandHandler + { + protected override void Handle(ServerListRequestCommand command) + { + // Do nothing, this is a packet for the global server + } + } +} diff --git a/src/csm/GS/Commands/Handler/ApiServer/ServerListResultHandler.cs b/src/csm/GS/Commands/Handler/ApiServer/ServerListResultHandler.cs new file mode 100644 index 00000000..da75584f --- /dev/null +++ b/src/csm/GS/Commands/Handler/ApiServer/ServerListResultHandler.cs @@ -0,0 +1,13 @@ +using CSM.GS.Commands.Data.ApiServer; +using CSM.Panels; + +namespace CSM.GS.Commands.Handler.ApiServer +{ + public class ServerListResultHandler : ApiCommandHandler + { + protected override void Handle(ServerListResultCommand command) + { + PanelManager.GetPanel()?.OnServerListReceived(command.Servers ?? new PublicServerEntry[0]); + } + } +} diff --git a/src/csm/Networking/PublicServerListing.cs b/src/csm/Networking/PublicServerListing.cs deleted file mode 100644 index 05c5a76a..00000000 --- a/src/csm/Networking/PublicServerListing.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace CSM.Networking -{ - /// - /// A single entry in the public server list, as returned by the GS - /// GET /api/servers HTTP endpoint. Parsed by CSM.Util.MiniJson, since - /// both UnityEngine.JsonUtility (fails to populate arrays of custom - /// types, class or struct) and Newtonsoft.Json (missing - /// System.Runtime.Serialization in the game's Mono runtime) are - /// unusable for this shape in this environment. - /// - public struct PublicServerListing - { - public string Name; - public int CurrentPlayers; - public int MaxPlayers; - public bool HasPassword; - public string Address; - public string ServerToken; - } - - /// - /// The response body of the GET /api/servers HTTP endpoint. - /// - public class PublicServerListResponse - { - public PublicServerListing[] Servers; - } -} diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index 0f0f0f16..68310bc3 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -1,19 +1,21 @@ using System; using System.Collections.Generic; -using System.Threading; -using ColossalFramework.Threading; +using System.Net; using ColossalFramework.UI; using CSM.API; +using CSM.GS.Commands; +using CSM.GS.Commands.Data.ApiServer; using CSM.Helpers; using CSM.Networking; -using CSM.Util; +using LiteNetLib; using UnityEngine; namespace CSM.Panels { public class BrowseServersPanel : UIPanel { - private const int PollIntervalMs = 10000; + private static readonly TimeSpan RequestInterval = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ResponseTimeout = TimeSpan.FromSeconds(5); private UIScrollablePanel _scrollablePanel; private UILabel _statusLabel; @@ -23,8 +25,9 @@ public class BrowseServersPanel : UIPanel private readonly List _rowLabels = new List(); private readonly List _joinButtons = new List(); - private Thread _pollThread; - private volatile bool _polling; + private LiteNetLib.NetManager _netManager; + private DateTime _lastRequestSent = DateTime.MinValue; + private bool _awaitingResponse; public override void Start() { @@ -53,7 +56,7 @@ public override void Start() this.AddScrollbar(_scrollablePanel); _refreshButton = this.CreateButton("Refresh", new Vector2(10, -430), 200); - _refreshButton.eventClick += (component, param) => new Thread(FetchServerList) { IsBackground = true }.Start(); + _refreshButton.eventClick += (component, param) => SendServerListRequest(); _closeButton = this.CreateButton("Close", new Vector2(220, -430), 200); _closeButton.eventClick += (component, param) => @@ -61,118 +64,95 @@ public override void Start() isVisible = false; }; - eventVisibilityChanged += (component, visible) => + SetupNetworking(); + } + + public override void Update() + { + _netManager?.PollEvents(); + + if (!isVisible) + return; + + if (DateTime.Now.Subtract(_lastRequestSent) >= RequestInterval) { - if (visible) - { - StartPolling(); - } - else - { - StopPolling(); - } - }; + SendServerListRequest(); + } + else if (_awaitingResponse && DateTime.Now.Subtract(_lastRequestSent) >= ResponseTimeout) + { + _awaitingResponse = false; + _statusLabel.text = "Failed to reach the API server. Check your CSM API Server settings."; + _statusLabel.isVisible = true; + } } public override void OnDestroy() { - StopPolling(); + _netManager?.Stop(); base.OnDestroy(); } - private void StartPolling() + private void SetupNetworking() { - if (_pollThread != null) - return; + EventBasedNetListener listener = new EventBasedNetListener(); + listener.NetworkReceiveUnconnectedEvent += OnNetworkReceiveUnconnectedEvent; - _polling = true; - _pollThread = new Thread(PollLoop) { IsBackground = true }; - _pollThread.Start(); + _netManager = new LiteNetLib.NetManager(listener) { UnconnectedMessagesEnabled = true }; + _netManager.Start(); } - private void StopPolling() + private void OnNetworkReceiveUnconnectedEvent(IPEndPoint from, NetPacketReader reader, UnconnectedMessageType type) { - _polling = false; - _pollThread = null; - } + if (type != UnconnectedMessageType.BasicMessage) + return; - private void PollLoop() - { - while (_polling) + try { - FetchServerList(); + // Only allow responses from the API server + if (!Equals(from.Address, IpAddress.GetIpv4(CSM.Settings.ApiServer))) + return; - // Sleep in small increments so we react quickly when the panel is hidden. - for (int i = 0; i < PollIntervalMs / 100 && _polling; i++) - { - Thread.Sleep(100); - } + CommandReceiver.Parse(reader); + } + catch (Exception ex) + { + Log.Warn($"Encountered an error while reading public server list response: {ex}"); } } - private void FetchServerList() + private void SendServerListRequest() { try { - ThreadHelper.dispatcher.Dispatch(() => + IPAddress apiServer = IpAddress.GetIpv4(CSM.Settings.ApiServer); + byte[] data = ApiCommand.Serialize(new ServerListRequestCommand()); + _netManager.SendUnconnectedMessage(data, new IPEndPoint(apiServer, CSM.Settings.ApiServerPort)); + + _lastRequestSent = DateTime.Now; + _awaitingResponse = true; + + if (_rowLabels.Count == 0 && _joinButtons.Count == 0) { - _statusLabel.text = "Fetching server list..."; + _statusLabel.text = "Loading..."; _statusLabel.isVisible = true; - }); - string url = $"http://{CSM.Settings.ApiServer}:{CSM.Settings.ApiServerHttpPort}/api/servers"; - string json = new CSMWebClient().DownloadString(url); - - // Parsed with MiniJson (not UnityEngine.JsonUtility or Newtonsoft.Json - see - // MiniJson.cs for why neither works for this shape in this environment). - // MiniJson is plain managed code, so unlike JsonUtility it's safe to parse - // off the main thread here; only the resulting UI update needs to be - // marshaled back via Dispatch. - PublicServerListing[] servers = ParseServers(json); - - ThreadHelper.dispatcher.Dispatch(() => UpdateRows(servers)); + } } catch (Exception e) { - Log.Warn($"Failed to fetch public server list: {e.Message}"); - ThreadHelper.dispatcher.Dispatch(() => - { - _statusLabel.text = "Failed to fetch the public server list."; - _statusLabel.isVisible = true; - }); + Log.Warn($"Failed to send public server list request: {e.Message}"); } } - private static PublicServerListing[] ParseServers(string json) + /// + /// Called by ServerListResultHandler when a response arrives. + /// + public void OnServerListReceived(PublicServerEntry[] servers) { - List servers = new List(); - - if (!(MiniJson.Parse(json) is Dictionary root) || - !root.TryGetValue("Servers", out object serversObj) || - !(serversObj is List serverList)) - { - return servers.ToArray(); - } - - foreach (object item in serverList) - { - if (!(item is Dictionary entry)) - continue; - - servers.Add(new PublicServerListing - { - Name = entry.TryGetValue("Name", out object name) ? (string)name : "", - CurrentPlayers = entry.TryGetValue("CurrentPlayers", out object cur) ? Convert.ToInt32(cur) : 0, - MaxPlayers = entry.TryGetValue("MaxPlayers", out object max) ? Convert.ToInt32(max) : 0, - HasPassword = entry.TryGetValue("HasPassword", out object pass) && (bool)pass, - Address = entry.TryGetValue("Address", out object addr) ? (string)addr : "", - ServerToken = entry.TryGetValue("ServerToken", out object tok) ? (string)tok : "", - }); - } - - return servers.ToArray(); + _awaitingResponse = false; + UpdateRows(servers); } - private void UpdateRows(PublicServerListing[] servers) + private void UpdateRows(PublicServerEntry[] servers) { foreach (UILabel label in _rowLabels) { @@ -199,7 +179,7 @@ private void UpdateRows(PublicServerListing[] servers) int rowOffset = 0; const int rowHeight = 40; - foreach (PublicServerListing server in servers) + foreach (PublicServerEntry server in servers) { string lockIcon = server.HasPassword ? " [Protected]" : ""; string name = string.IsNullOrEmpty(server.Name) ? "Unnamed Server" : server.Name; @@ -211,26 +191,35 @@ private void UpdateRows(PublicServerListing[] servers) _rowLabels.Add(serverLabel); UIButton joinButton = _scrollablePanel.CreateButton("Join", new Vector2(280, rowOffset), 100, rowHeight); - string address = server.Address; - joinButton.eventClick += (component, param) => OnJoinClick(address); + PublicServerEntry capturedServer = server; + joinButton.eventClick += (component, param) => OnJoinClick(capturedServer); _joinButtons.Add(joinButton); rowOffset -= rowHeight; } } - private void OnJoinClick(string address) + private void OnJoinClick(PublicServerEntry server) { - string[] parts = address.Split(':'); - if (parts.Length != 2 || !int.TryParse(parts[1], out int port)) + isVisible = false; + + JoinGamePanel joinPanel = PanelManager.ShowPanel(); + + // Prefer the NAT-relay token: it works even when the host's direct + // address isn't reachable (e.g. behind NAT without port forwarding). + if (!string.IsNullOrEmpty(server.ServerToken)) { - Log.Warn($"Received invalid server address from public server list: {address}"); + joinPanel.PrefillJoinAddress($"token:{server.ServerToken}", 0); return; } - isVisible = false; + string[] parts = server.Address.Split(':'); + if (parts.Length != 2 || !int.TryParse(parts[1], out int port)) + { + Log.Warn($"Received invalid server address from public server list: {server.Address}"); + return; + } - JoinGamePanel joinPanel = PanelManager.ShowPanel(); joinPanel.PrefillJoinAddress(parts[0], port); } } diff --git a/src/csm/Settings.cs b/src/csm/Settings.cs index 9ee82d88..487f450a 100644 --- a/src/csm/Settings.cs +++ b/src/csm/Settings.cs @@ -49,7 +49,9 @@ public bool UseChirper new SavedInt(nameof(ApiServerPort), SettingsFile, DefaultApiServerPort, true); /// - /// The port that the API server's public server list HTTP endpoint runs on. + /// The port that the API server's HTTP endpoint runs on. Only used for the + /// update/version check (/api/version) - the public server list is served + /// over UDP on ApiServerPort, not HTTP. /// public readonly SavedInt ApiServerHttpPort = new SavedInt(nameof(ApiServerHttpPort), SettingsFile, DefaultApiServerHttpPort, true); diff --git a/src/csm/Util/MiniJson.cs b/src/csm/Util/MiniJson.cs deleted file mode 100644 index 3d62b0ee..00000000 --- a/src/csm/Util/MiniJson.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Text; - -namespace CSM.Util -{ - /// - /// A small, dependency-free JSON parser. Exists because, in this Unity/Mono - /// environment, UnityEngine.JsonUtility fails to deserialize arrays of custom - /// types (class or struct) and Newtonsoft.Json's default contract resolver - /// depends on System.Runtime.Serialization, which isn't part of the game's - /// bundled Mono runtime. Parses into plain object graphs: Dictionary<string, object> - /// for objects, List<object> for arrays, string, double, bool, or null for scalars. - /// - public static class MiniJson - { - public static object Parse(string json) - { - int index = 0; - object result = ParseValue(json, ref index); - return result; - } - - private static object ParseValue(string json, ref int index) - { - SkipWhitespace(json, ref index); - - char c = json[index]; - switch (c) - { - case '{': - return ParseObject(json, ref index); - case '[': - return ParseArray(json, ref index); - case '"': - return ParseString(json, ref index); - case 't': - case 'f': - return ParseBool(json, ref index); - case 'n': - index += 4; // "null" - return null; - default: - return ParseNumber(json, ref index); - } - } - - private static Dictionary ParseObject(string json, ref int index) - { - Dictionary result = new Dictionary(); - - index++; // consume '{' - SkipWhitespace(json, ref index); - - if (json[index] == '}') - { - index++; - return result; - } - - while (true) - { - SkipWhitespace(json, ref index); - string key = ParseString(json, ref index); - - SkipWhitespace(json, ref index); - index++; // consume ':' - - object value = ParseValue(json, ref index); - result[key] = value; - - SkipWhitespace(json, ref index); - if (json[index] == ',') - { - index++; - continue; - } - - index++; // consume '}' - break; - } - - return result; - } - - private static List ParseArray(string json, ref int index) - { - List result = new List(); - - index++; // consume '[' - SkipWhitespace(json, ref index); - - if (json[index] == ']') - { - index++; - return result; - } - - while (true) - { - object value = ParseValue(json, ref index); - result.Add(value); - - SkipWhitespace(json, ref index); - if (json[index] == ',') - { - index++; - continue; - } - - index++; // consume ']' - break; - } - - return result; - } - - private static string ParseString(string json, ref int index) - { - StringBuilder sb = new StringBuilder(); - - index++; // consume opening '"' - while (json[index] != '"') - { - char c = json[index]; - if (c == '\\') - { - index++; - char escaped = json[index]; - switch (escaped) - { - case '"': sb.Append('"'); break; - case '\\': sb.Append('\\'); break; - case '/': sb.Append('/'); break; - case 'b': sb.Append('\b'); break; - case 'f': sb.Append('\f'); break; - case 'n': sb.Append('\n'); break; - case 'r': sb.Append('\r'); break; - case 't': sb.Append('\t'); break; - case 'u': - string hex = json.Substring(index + 1, 4); - sb.Append((char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture)); - index += 4; - break; - } - } - else - { - sb.Append(c); - } - - index++; - } - - index++; // consume closing '"' - return sb.ToString(); - } - - private static bool ParseBool(string json, ref int index) - { - if (json[index] == 't') - { - index += 4; // "true" - return true; - } - - index += 5; // "false" - return false; - } - - private static double ParseNumber(string json, ref int index) - { - int start = index; - while (index < json.Length && (char.IsDigit(json[index]) || json[index] == '-' || json[index] == '+' || - json[index] == '.' || json[index] == 'e' || json[index] == 'E')) - { - index++; - } - - return double.Parse(json.Substring(start, index - start), CultureInfo.InvariantCulture); - } - - private static void SkipWhitespace(string json, ref int index) - { - while (index < json.Length && char.IsWhiteSpace(json[index])) - { - index++; - } - } - } -} diff --git a/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs b/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs new file mode 100644 index 00000000..71e1fe9f --- /dev/null +++ b/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs @@ -0,0 +1,38 @@ +using ProtoBuf; + +namespace CSM.GS.Commands.Data.ApiServer +{ + /// + /// A single entry in the public server list. Only contains information + /// safe to expose publicly (no token, no internal/external IP distinction). + /// + [ProtoContract] + public class PublicServerEntry + { + [ProtoMember(1)] + public string Name { get; set; } + + [ProtoMember(2)] + public int CurrentPlayers { get; set; } + + [ProtoMember(3)] + public int MaxPlayers { get; set; } + + [ProtoMember(4)] + public bool HasPassword { get; set; } + + /// + /// The address (ip:port) that a client should connect to in order to join. + /// + [ProtoMember(5)] + public string Address { get; set; } + + /// + /// The server's NAT-relay token, usable as a fallback join method (via + /// "token:" prefixed address in JoinGamePanel) when the direct address + /// isn't reachable (e.g. the host is behind NAT without port forwarding). + /// + [ProtoMember(6)] + public string ServerToken { get; set; } + } +} diff --git a/src/gs/Commands/Data/ApiServer/ServerListRequestCommand.cs b/src/gs/Commands/Data/ApiServer/ServerListRequestCommand.cs new file mode 100644 index 00000000..35716afa --- /dev/null +++ b/src/gs/Commands/Data/ApiServer/ServerListRequestCommand.cs @@ -0,0 +1,14 @@ +using ProtoBuf; + +namespace CSM.GS.Commands.Data.ApiServer +{ + /// + /// Requests the current list of publicly-listed servers from the API server. + /// + /// Sent by: + /// - Mod (browsing client) + [ProtoContract] + public class ServerListRequestCommand : ApiCommandBase + { + } +} diff --git a/src/gs/Commands/Data/ApiServer/ServerListResultCommand.cs b/src/gs/Commands/Data/ApiServer/ServerListResultCommand.cs new file mode 100644 index 00000000..a76da41e --- /dev/null +++ b/src/gs/Commands/Data/ApiServer/ServerListResultCommand.cs @@ -0,0 +1,17 @@ +using ProtoBuf; + +namespace CSM.GS.Commands.Data.ApiServer +{ + /// + /// The response to a ServerListRequestCommand, containing the current + /// list of publicly-listed servers. + /// + /// Sent by: + /// - API server + [ProtoContract] + public class ServerListResultCommand : ApiCommandBase + { + [ProtoMember(1)] + public PublicServerEntry[] Servers { get; set; } + } +} diff --git a/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs b/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs new file mode 100644 index 00000000..aa295f86 --- /dev/null +++ b/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs @@ -0,0 +1,23 @@ +using System.Linq; +using CSM.GS.Commands.Data.ApiServer; + +namespace CSM.GS.Commands.Handler.ApiServer +{ + public class ServerListRequestHandler : ApiCommandHandler + { + protected override void Handle(ServerListRequestCommand command) + { + PublicServerEntry[] servers = worker.PublicServers.Select(server => new PublicServerEntry + { + Name = server.ServerName, + CurrentPlayers = server.CurrentPlayers, + MaxPlayers = server.MaxPlayers, + HasPassword = server.HasPassword, + Address = $"{server.ExternalAddress.Address}:{server.ExternalAddress.Port}", + ServerToken = server.Token + }).ToArray(); + + worker.SendToServer(sender, new ServerListResultCommand { Servers = servers }); + } + } +} diff --git a/src/gs/Commands/Handler/ApiServer/ServerListResultHandler.cs b/src/gs/Commands/Handler/ApiServer/ServerListResultHandler.cs new file mode 100644 index 00000000..636dfbd5 --- /dev/null +++ b/src/gs/Commands/Handler/ApiServer/ServerListResultHandler.cs @@ -0,0 +1,12 @@ +using CSM.GS.Commands.Data.ApiServer; + +namespace CSM.GS.Commands.Handler.ApiServer +{ + public class ServerListResultHandler : ApiCommandHandler + { + protected override void Handle(ServerListResultCommand command) + { + // Do nothing, this is a packet for the browsing client + } + } +} diff --git a/src/gs/Http/PublicServerListing.cs b/src/gs/Http/PublicServerListing.cs deleted file mode 100644 index 231da4c4..00000000 --- a/src/gs/Http/PublicServerListing.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace CSM.GS.Http -{ - /// - /// A single entry in the public server list, as returned by the - /// GET /api/servers HTTP endpoint. Only contains information safe - /// to expose publicly (no token, no internal/external IP distinction). - /// - public class PublicServerListing - { - public string Name { get; set; } - - public int CurrentPlayers { get; set; } - - public int MaxPlayers { get; set; } - - public bool HasPassword { get; set; } - - /// - /// The address (ip:port) that a client should connect to in order to join. - /// - public string Address { get; set; } - public string ServerToken { get; set; } - } - - /// - /// The response body of the GET /api/servers HTTP endpoint. The listings are - /// wrapped in an object (rather than returned as a bare JSON array) since Unity's - /// JsonUtility, used to parse this on the mod side, cannot deserialize root arrays. - /// - public class PublicServerListResponse - { - public PublicServerListing[] Servers { get; set; } - } -} diff --git a/src/gs/Program.cs b/src/gs/Program.cs index ec3425ae..7c4ce31f 100644 --- a/src/gs/Program.cs +++ b/src/gs/Program.cs @@ -1,9 +1,5 @@ -using System.Linq; -using System.Security.Principal; -using CSM.GS.Http; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -13,8 +9,10 @@ namespace CSM.GS { /// /// This is the UDP hole punching server, the clients will connect - /// to this server to setup the correct ports. It also exposes an - /// HTTP endpoint listing servers that have opted in to public listing. + /// to this server to setup the correct ports. It also exposes a small + /// HTTP endpoint for the mod's update/version check. The public server + /// list itself is served over the UDP protocol (ServerListRequestCommand/ + /// ServerListResultCommand), not HTTP. /// public static class Program { @@ -32,14 +30,6 @@ public static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); - // Keep PascalCase property names (disable the default camelCase policy) since - // the mod parses this JSON with Unity's JsonUtility, which matches field names - // case-sensitively against the C# field names (Name, CurrentPlayers, etc.). - builder.Services.Configure(options => - { - options.SerializerOptions.PropertyNamingPolicy = null; - }); - int httpPort = int.TryParse(builder.Configuration.GetSection("HTTP_PORT").Value, out int val) ? val : 4241; builder.WebHost.UseUrls($"http://0.0.0.0:{httpPort}"); @@ -50,20 +40,6 @@ public static void Main(string[] args) // request to succeed; the version itself isn't otherwise used here). app.MapGet("/api/version", () => "v0.0"); - app.MapGet("/api/servers", (WorkerService worker) => - new PublicServerListResponse - { - Servers = worker.PublicServers.Select(server => new PublicServerListing - { - Name = server.ServerName, - CurrentPlayers = server.CurrentPlayers, - MaxPlayers = server.MaxPlayers, - HasPassword = server.HasPassword, - Address = $"{server.ExternalAddress.Address}:{server.ExternalAddress.Port}", - ServerToken = server.ServerToken, - }).ToArray() - }); - app.Run(); } } From 4a8930d19c12e055948d30e87d01ef4111207cfb Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:57:04 -0400 Subject: [PATCH 17/24] prompt for password if the server has a password --- src/csm/CSM.csproj | 1 + src/csm/Panels/BrowseServersPanel.cs | 24 +++-- src/csm/Panels/JoinGamePanel.cs | 10 +- src/csm/Panels/PasswordPromptPanel.cs | 130 ++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 src/csm/Panels/PasswordPromptPanel.cs diff --git a/src/csm/CSM.csproj b/src/csm/CSM.csproj index 8391327b..909754b7 100644 --- a/src/csm/CSM.csproj +++ b/src/csm/CSM.csproj @@ -173,6 +173,7 @@ + diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index 68310bc3..b6748e74 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -201,18 +201,6 @@ private void UpdateRows(PublicServerEntry[] servers) private void OnJoinClick(PublicServerEntry server) { - isVisible = false; - - JoinGamePanel joinPanel = PanelManager.ShowPanel(); - - // Prefer the NAT-relay token: it works even when the host's direct - // address isn't reachable (e.g. behind NAT without port forwarding). - if (!string.IsNullOrEmpty(server.ServerToken)) - { - joinPanel.PrefillJoinAddress($"token:{server.ServerToken}", 0); - return; - } - string[] parts = server.Address.Split(':'); if (parts.Length != 2 || !int.TryParse(parts[1], out int port)) { @@ -220,7 +208,17 @@ private void OnJoinClick(PublicServerEntry server) return; } - joinPanel.PrefillJoinAddress(parts[0], port); + isVisible = false; + + if (server.HasPassword) + { + PanelManager.ShowPanel().Show(parts[0], port); + } + else + { + JoinGamePanel joinPanel = PanelManager.ShowPanel(); + joinPanel.JoinServer(parts[0], port); + } } } } diff --git a/src/csm/Panels/JoinGamePanel.cs b/src/csm/Panels/JoinGamePanel.cs index 29904d19..668224a9 100644 --- a/src/csm/Panels/JoinGamePanel.cs +++ b/src/csm/Panels/JoinGamePanel.cs @@ -256,16 +256,20 @@ void Action() } /// - /// Prefills the IP address and port fields, for example when joining - /// a server picked from the public server browser. + /// Fills in the IP address and port fields and immediately connects, + /// for example when joining a password-less server picked from the + /// public server browser - skips the extra manual "Connect to Server" + /// click. Password-protected servers are instead routed to + /// PasswordPromptPanel, which doesn't expose the IP/port fields. /// - public void PrefillJoinAddress(string ip, int port) + public void JoinServer(string ip, int port) { void Action() { _ipAddressField.text = ip; _portField.text = port.ToString(); _connectionStatus.text = ""; + OnConnectButtonClick(null, null); } if (_connectionStatus != null) diff --git a/src/csm/Panels/PasswordPromptPanel.cs b/src/csm/Panels/PasswordPromptPanel.cs new file mode 100644 index 00000000..2c1216ab --- /dev/null +++ b/src/csm/Panels/PasswordPromptPanel.cs @@ -0,0 +1,130 @@ +using ColossalFramework.PlatformServices; +using ColossalFramework.Threading; +using ColossalFramework.UI; +using CSM.API; +using CSM.API.Commands; +using CSM.Helpers; +using CSM.Networking; +using CSM.Networking.Config; +using UnityEngine; + +namespace CSM.Panels +{ + /// + /// A focused password-entry prompt used when joining a password-protected + /// server from the public server browser. Deliberately doesn't expose the + /// target IP/port in the UI - unlike manually typing an address into + /// JoinGamePanel, a browsed server's address wasn't something the player + /// entered themselves, so there's no need to display it. + /// + public class PasswordPromptPanel : UIPanel + { + private UITextField _passwordField; + private UILabel _connectionStatus; + private UIButton _connectButton; + private UIButton _cancelButton; + + private string _targetIp; + private int _targetPort; + + public override void Start() + { + AddUIComponent(typeof(UIDragHandle)); + + backgroundSprite = "GenericPanel"; + color = new Color32(110, 110, 110, 255); + + width = 360; + height = 260; + relativePosition = PanelManager.GetCenterPosition(this); + + this.CreateTitleLabel("Password Required", new Vector2(80, -20)); + + this.CreateLabel("This server requires a password to join.", new Vector2(10, -60), 340); + + _passwordField = this.CreateTextField("", new Vector2(10, -95)); + _passwordField.isPasswordField = true; + + _connectionStatus = this.CreateLabel("", new Vector2(10, -140)); + _connectionStatus.textAlignment = UIHorizontalAlignment.Center; + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + + _connectButton = this.CreateButton("Connect", new Vector2(10, -180), 165); + _connectButton.eventClick += OnConnectClick; + + _cancelButton = this.CreateButton("Cancel", new Vector2(185, -180), 165); + _cancelButton.eventClick += (component, param) => + { + isVisible = false; + }; + } + + public override void Update() + { + if (isVisible && Input.GetKeyDown(KeyCode.Escape)) + { + isVisible = false; + } + } + + /// + /// Shows the prompt for the given target server. The IP/port are kept + /// only in memory here, never rendered in the UI. + /// + public void Show(string ip, int port) + { + _targetIp = ip; + _targetPort = port; + + _passwordField.text = ""; + _connectionStatus.text = ""; + + isVisible = true; + } + + private void OnConnectClick(UIComponent component, UIMouseEventParameter param) + { + if (MultiplayerManager.Instance.CurrentRole == MultiplayerRole.Server) + { + _connectionStatus.text = "Already Running Server"; + return; + } + + ClientConfig savedConfig = null; + ConfigData.Load(ref savedConfig, ConfigData.ClientFile); + + string username = savedConfig?.Username; + if (string.IsNullOrEmpty(username)) + { + username = PlatformService.active && PlatformService.personaName != null + ? PlatformService.personaName + : "Player"; + } + + ClientConfig clientConfig = new ClientConfig(_targetIp, _targetPort, username, _passwordField.text); + + _connectionStatus.textColor = new Color32(255, 255, 0, 255); + _connectionStatus.text = "Connecting..."; + + MultiplayerManager.Instance.CurrentClient.StartMainMenuEventProcessor(); + + MultiplayerManager.Instance.ConnectToServer(clientConfig, success => + { + ThreadHelper.dispatcher.Dispatch(() => + { + if (!success) + { + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + _connectionStatus.text = MultiplayerManager.Instance.CurrentClient.ConnectionMessage; + } + else + { + _connectionStatus.text = ""; + isVisible = false; + MultiplayerManager.Instance.BlockGameFirstJoin(); + } + }); + }); + } + } +} From 9f8b3e89064cf2d52078f230337db4862870ec9a Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:00:19 -0400 Subject: [PATCH 18/24] prefill username from last username used with several fallbacks --- src/csm/Panels/PasswordPromptPanel.cs | 41 ++++++++++++++++++--------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/csm/Panels/PasswordPromptPanel.cs b/src/csm/Panels/PasswordPromptPanel.cs index 2c1216ab..a8cf6026 100644 --- a/src/csm/Panels/PasswordPromptPanel.cs +++ b/src/csm/Panels/PasswordPromptPanel.cs @@ -19,6 +19,7 @@ namespace CSM.Panels /// public class PasswordPromptPanel : UIPanel { + private UITextField _usernameField; private UITextField _passwordField; private UILabel _connectionStatus; private UIButton _connectButton; @@ -35,24 +36,28 @@ public override void Start() color = new Color32(110, 110, 110, 255); width = 360; - height = 260; + height = 335; relativePosition = PanelManager.GetCenterPosition(this); this.CreateTitleLabel("Password Required", new Vector2(80, -20)); this.CreateLabel("This server requires a password to join.", new Vector2(10, -60), 340); - _passwordField = this.CreateTextField("", new Vector2(10, -95)); + this.CreateLabel("Username:", new Vector2(10, -95)); + _usernameField = this.CreateTextField("", new Vector2(10, -120)); + + this.CreateLabel("Password:", new Vector2(10, -160)); + _passwordField = this.CreateTextField("", new Vector2(10, -185)); _passwordField.isPasswordField = true; - _connectionStatus = this.CreateLabel("", new Vector2(10, -140)); + _connectionStatus = this.CreateLabel("", new Vector2(10, -225)); _connectionStatus.textAlignment = UIHorizontalAlignment.Center; _connectionStatus.textColor = new Color32(255, 0, 0, 255); - _connectButton = this.CreateButton("Connect", new Vector2(10, -180), 165); + _connectButton = this.CreateButton("Connect", new Vector2(10, -265), 165); _connectButton.eventClick += OnConnectClick; - _cancelButton = this.CreateButton("Cancel", new Vector2(185, -180), 165); + _cancelButton = this.CreateButton("Cancel", new Vector2(185, -265), 165); _cancelButton.eventClick += (component, param) => { isVisible = false; @@ -76,6 +81,18 @@ public void Show(string ip, int port) _targetIp = ip; _targetPort = port; + ClientConfig savedConfig = null; + ConfigData.Load(ref savedConfig, ConfigData.ClientFile); + + string username = savedConfig?.Username; + if (string.IsNullOrEmpty(username)) + { + username = PlatformService.active && PlatformService.personaName != null + ? PlatformService.personaName + : "Player"; + } + + _usernameField.text = username; _passwordField.text = ""; _connectionStatus.text = ""; @@ -90,18 +107,14 @@ private void OnConnectClick(UIComponent component, UIMouseEventParameter param) return; } - ClientConfig savedConfig = null; - ConfigData.Load(ref savedConfig, ConfigData.ClientFile); - - string username = savedConfig?.Username; - if (string.IsNullOrEmpty(username)) + if (string.IsNullOrEmpty(_usernameField.text)) { - username = PlatformService.active && PlatformService.personaName != null - ? PlatformService.personaName - : "Player"; + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + _connectionStatus.text = "Invalid Username"; + return; } - ClientConfig clientConfig = new ClientConfig(_targetIp, _targetPort, username, _passwordField.text); + ClientConfig clientConfig = new ClientConfig(_targetIp, _targetPort, _usernameField.text, _passwordField.text); _connectionStatus.textColor = new Color32(255, 255, 0, 255); _connectionStatus.text = "Connecting..."; From ca5f3428b97f4360e3f275618c6d9f32d6d12c5e Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:19:54 -0400 Subject: [PATCH 19/24] only poll when the panel is open --- src/csm/Panels/BrowseServersPanel.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index b6748e74..48ea4c6a 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -69,10 +69,9 @@ public override void Start() public override void Update() { - _netManager?.PollEvents(); - if (!isVisible) return; + _netManager?.PollEvents(); if (DateTime.Now.Subtract(_lastRequestSent) >= RequestInterval) { From fe0fab8816f985afabb6851ec9db2b910ee223cb Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:27:05 -0400 Subject: [PATCH 20/24] join public/Steam servers through a shared token-based flow extract SteamHelpers' connect-by-token logic into JoinGamePanel.JoinByToken, reused by both the Steam friend-invite path and the public server browser - BrowseServersPanel now joins via ServerToken instead of the raw address, and PasswordPromptPanel delegates to the same shared method instead of duplicating the connect logic --- src/csm/Helpers/SteamHelpers.cs | 32 +---------- src/csm/Panels/BrowseServersPanel.cs | 29 ++++++++-- src/csm/Panels/JoinGamePanel.cs | 65 +++++++++++++++------- src/csm/Panels/PasswordPromptPanel.cs | 80 ++++++++++----------------- 4 files changed, 98 insertions(+), 108 deletions(-) diff --git a/src/csm/Helpers/SteamHelpers.cs b/src/csm/Helpers/SteamHelpers.cs index e53eaf3e..9c4534c9 100644 --- a/src/csm/Helpers/SteamHelpers.cs +++ b/src/csm/Helpers/SteamHelpers.cs @@ -5,11 +5,8 @@ using System.Threading; using ColossalFramework; using ColossalFramework.PlatformServices; -using ColossalFramework.Threading; using CSM.API; using CSM.Helpers.Steamworks; -using CSM.Networking; -using CSM.Networking.Config; using CSM.Panels; namespace CSM.Helpers @@ -135,34 +132,7 @@ private void JoinFromSteam(string token, bool checkLoadingManager) return; } - Log.Info("Join request for " + token); - - JoinGamePanel join = PanelManager.ShowPanel(); - join.SetConnecting(); - - MultiplayerManager.Instance.CurrentClient.StartMainMenuEventProcessor(); - MultiplayerManager.Instance.ConnectToServer(new ClientConfig(token, PlatformService.personaName), - (success) => - { - if (success) - { - // See WorldTransferHandler for actual loading - ThreadHelper.dispatcher.Dispatch(() => - { - MultiplayerManager.Instance.BlockGameFirstJoin(); - PanelManager.HidePanel(); - }); - } - else - { - ThreadHelper.dispatcher.Dispatch(() => - { - JoinGamePanel panel = PanelManager.ShowPanel(); - panel.FillFieldsOnError(token, PlatformService.personaName, - MultiplayerManager.Instance.CurrentClient.ConnectionMessage); - }); - } - }); + JoinGamePanel.JoinByToken(token, PlatformService.personaName); } public void Shutdown() diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index 48ea4c6a..779f192b 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Net; +using ColossalFramework.PlatformServices; using ColossalFramework.UI; using CSM.API; using CSM.GS.Commands; using CSM.GS.Commands.Data.ApiServer; using CSM.Helpers; using CSM.Networking; +using CSM.Networking.Config; using LiteNetLib; using UnityEngine; @@ -200,24 +202,39 @@ private void UpdateRows(PublicServerEntry[] servers) private void OnJoinClick(PublicServerEntry server) { - string[] parts = server.Address.Split(':'); - if (parts.Length != 2 || !int.TryParse(parts[1], out int port)) + if (string.IsNullOrEmpty(server.ServerToken)) { - Log.Warn($"Received invalid server address from public server list: {server.Address}"); + Log.Warn($"Public server list entry for '{server.Name}' has no token, cannot join."); return; } isVisible = false; + string username = GetSavedOrDefaultUsername(); + if (server.HasPassword) { - PanelManager.ShowPanel().Show(parts[0], port); + PanelManager.ShowPanel().Show(server.ServerToken, username); } else { - JoinGamePanel joinPanel = PanelManager.ShowPanel(); - joinPanel.JoinServer(parts[0], port); + JoinGamePanel.JoinByToken(server.ServerToken, username); + } + } + + private static string GetSavedOrDefaultUsername() + { + ClientConfig savedConfig = null; + ConfigData.Load(ref savedConfig, ConfigData.ClientFile); + + if (!string.IsNullOrEmpty(savedConfig?.Username)) + { + return savedConfig.Username; } + + return PlatformService.active && PlatformService.personaName != null + ? PlatformService.personaName + : "Player"; } } } diff --git a/src/csm/Panels/JoinGamePanel.cs b/src/csm/Panels/JoinGamePanel.cs index 668224a9..73d50bbc 100644 --- a/src/csm/Panels/JoinGamePanel.cs +++ b/src/csm/Panels/JoinGamePanel.cs @@ -2,6 +2,7 @@ using ColossalFramework.PlatformServices; using ColossalFramework.Threading; using ColossalFramework.UI; +using CSM.API; using CSM.API.Commands; using CSM.Helpers; using CSM.Mods; @@ -256,30 +257,54 @@ void Action() } /// - /// Fills in the IP address and port fields and immediately connects, - /// for example when joining a password-less server picked from the - /// public server browser - skips the extra manual "Connect to Server" - /// click. Password-protected servers are instead routed to - /// PasswordPromptPanel, which doesn't expose the IP/port fields. + /// Attempts to join a server by its NAT-relay token (rather than a + /// direct IP/port). Shared by the Steam friend-invite join flow and + /// the public server browser join flow, so both behave consistently: + /// on success, hide this panel and load into the game; on failure, + /// show a password prompt if that's the failure reason, otherwise + /// fall back to this panel's manual form with the error shown. /// - public void JoinServer(string ip, int port) + public static void JoinByToken(string token, string username, string password = null) { - void Action() - { - _ipAddressField.text = ip; - _portField.text = port.ToString(); - _connectionStatus.text = ""; - OnConnectButtonClick(null, null); - } + Log.Info("Join request for " + token); - if (_connectionStatus != null) - { - Action(); - } - else + JoinGamePanel join = PanelManager.ShowPanel(); + join.SetConnecting(); + + MultiplayerManager.Instance.CurrentClient.StartMainMenuEventProcessor(); + + ClientConfig clientConfig = password != null + ? new ClientConfig(token, username, password) + : new ClientConfig(token, username); + + MultiplayerManager.Instance.ConnectToServer(clientConfig, success => { - _onStarted = Action; - } + if (success) + { + ThreadHelper.dispatcher.Dispatch(() => + { + MultiplayerManager.Instance.BlockGameFirstJoin(); + PanelManager.HidePanel(); + }); + return; + } + + string reason = MultiplayerManager.Instance.CurrentClient.ConnectionMessage; + + ThreadHelper.dispatcher.Dispatch(() => + { + if (reason != null && reason.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0) + { + PanelManager.HidePanel(); + PanelManager.ShowPanel().Show(token, username, reason); + } + else + { + JoinGamePanel panel = PanelManager.ShowPanel(); + panel.FillFieldsOnError(token, username, reason); + } + }); + }); } public void SetConnecting() diff --git a/src/csm/Panels/PasswordPromptPanel.cs b/src/csm/Panels/PasswordPromptPanel.cs index a8cf6026..08c7f40d 100644 --- a/src/csm/Panels/PasswordPromptPanel.cs +++ b/src/csm/Panels/PasswordPromptPanel.cs @@ -1,21 +1,19 @@ -using ColossalFramework.PlatformServices; -using ColossalFramework.Threading; using ColossalFramework.UI; -using CSM.API; using CSM.API.Commands; using CSM.Helpers; using CSM.Networking; -using CSM.Networking.Config; using UnityEngine; namespace CSM.Panels { /// /// A focused password-entry prompt used when joining a password-protected - /// server from the public server browser. Deliberately doesn't expose the - /// target IP/port in the UI - unlike manually typing an address into - /// JoinGamePanel, a browsed server's address wasn't something the player - /// entered themselves, so there's no need to display it. + /// server (from the public server browser, or a retry after JoinByToken + /// failed with a password-related error). Deliberately doesn't expose the + /// target token/address in the UI - a browsed server's connection details + /// weren't something the player entered themselves, so there's no need to + /// display them. Delegates the actual connection attempt to + /// JoinGamePanel.JoinByToken. /// public class PasswordPromptPanel : UIPanel { @@ -25,8 +23,7 @@ public class PasswordPromptPanel : UIPanel private UIButton _connectButton; private UIButton _cancelButton; - private string _targetIp; - private int _targetPort; + private string _targetToken; public override void Start() { @@ -73,28 +70,30 @@ public override void Update() } /// - /// Shows the prompt for the given target server. The IP/port are kept - /// only in memory here, never rendered in the UI. + /// Shows the prompt for the given target server token. The token is + /// kept only in memory here, never rendered in the UI. /// - public void Show(string ip, int port) + /// The server's NAT-relay token. + /// The username to pre-fill (editable). + /// + /// An optional error to display, e.g. when re-showing this prompt + /// after a wrong password was entered. + /// + public void Show(string token, string username, string errorMessage = null) { - _targetIp = ip; - _targetPort = port; - - ClientConfig savedConfig = null; - ConfigData.Load(ref savedConfig, ConfigData.ClientFile); + _targetToken = token; + _usernameField.text = username; + _passwordField.text = ""; - string username = savedConfig?.Username; - if (string.IsNullOrEmpty(username)) + if (!string.IsNullOrEmpty(errorMessage)) { - username = PlatformService.active && PlatformService.personaName != null - ? PlatformService.personaName - : "Player"; + _connectionStatus.textColor = new Color32(255, 0, 0, 255); + _connectionStatus.text = errorMessage; + } + else + { + _connectionStatus.text = ""; } - - _usernameField.text = username; - _passwordField.text = ""; - _connectionStatus.text = ""; isVisible = true; } @@ -103,6 +102,7 @@ private void OnConnectClick(UIComponent component, UIMouseEventParameter param) { if (MultiplayerManager.Instance.CurrentRole == MultiplayerRole.Server) { + _connectionStatus.textColor = new Color32(255, 0, 0, 255); _connectionStatus.text = "Already Running Server"; return; } @@ -114,30 +114,8 @@ private void OnConnectClick(UIComponent component, UIMouseEventParameter param) return; } - ClientConfig clientConfig = new ClientConfig(_targetIp, _targetPort, _usernameField.text, _passwordField.text); - - _connectionStatus.textColor = new Color32(255, 255, 0, 255); - _connectionStatus.text = "Connecting..."; - - MultiplayerManager.Instance.CurrentClient.StartMainMenuEventProcessor(); - - MultiplayerManager.Instance.ConnectToServer(clientConfig, success => - { - ThreadHelper.dispatcher.Dispatch(() => - { - if (!success) - { - _connectionStatus.textColor = new Color32(255, 0, 0, 255); - _connectionStatus.text = MultiplayerManager.Instance.CurrentClient.ConnectionMessage; - } - else - { - _connectionStatus.text = ""; - isVisible = false; - MultiplayerManager.Instance.BlockGameFirstJoin(); - } - }); - }); + isVisible = false; + JoinGamePanel.JoinByToken(_targetToken, _usernameField.text, _passwordField.text); } } } From 52bc2a6a751c8b627c5e9aa79be78cf30841c841 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:25:24 -0400 Subject: [PATCH 21/24] revert changes that create HTTP server --- src/gs/CSM.GS.csproj | 2 +- src/gs/Dockerfile | 4 +--- src/gs/Program.cs | 52 ++++++++++++++++---------------------------- 3 files changed, 21 insertions(+), 37 deletions(-) diff --git a/src/gs/CSM.GS.csproj b/src/gs/CSM.GS.csproj index 3e8ec9a5..bb8d9d29 100644 --- a/src/gs/CSM.GS.csproj +++ b/src/gs/CSM.GS.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/src/gs/Dockerfile b/src/gs/Dockerfile index e1206547..ac0776a1 100644 --- a/src/gs/Dockerfile +++ b/src/gs/Dockerfile @@ -7,9 +7,7 @@ RUN dotnet restore COPY . ./ RUN dotnet publish -c Release -o out -FROM mcr.microsoft.com/dotnet/aspnet:7.0 +FROM mcr.microsoft.com/dotnet/runtime:7.0 WORKDIR /app COPY --from=build-env /app/out . -EXPOSE 4240/udp -EXPOSE 4241/tcp ENTRYPOINT ["dotnet", "CSM.GS.dll"] diff --git a/src/gs/Program.cs b/src/gs/Program.cs index 7c4ce31f..294b2743 100644 --- a/src/gs/Program.cs +++ b/src/gs/Program.cs @@ -1,6 +1,4 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -9,38 +7,26 @@ namespace CSM.GS { /// /// This is the UDP hole punching server, the clients will connect - /// to this server to setup the correct ports. It also exposes a small - /// HTTP endpoint for the mod's update/version check. The public server - /// list itself is served over the UDP protocol (ServerListRequestCommand/ - /// ServerListResultCommand), not HTTP. + /// to this server to setup the correct ports /// public static class Program { - public static void Main(string[] args) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - - builder.Configuration.AddJsonFile("settings.json", true, true); - builder.Configuration.AddEnvironmentVariables("GS_"); - builder.Configuration.AddCommandLine(args); - - builder.Logging.ClearProviders(); - builder.Logging.AddConsole(); - - builder.Services.AddSingleton(); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); - - int httpPort = int.TryParse(builder.Configuration.GetSection("HTTP_PORT").Value, out int val) ? val : 4241; - builder.WebHost.UseUrls($"http://0.0.0.0:{httpPort}"); - - WebApplication app = builder.Build(); - - // The mod pings this to check for updates and to validate a custom - // API server URL entered in settings (CSMWebClient just needs the - // request to succeed; the version itself isn't otherwise used here). - app.MapGet("/api/version", () => "v0.0"); - - app.Run(); - } + public static void Main(string[] args) => CreateHostBuilder(args).Build().Run(); + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((_, config) => + { + config.AddJsonFile("settings.json", true, true); + config.AddEnvironmentVariables("GS_"); + config.AddCommandLine(args); + }) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }) + .ConfigureServices((_, services) => + services.AddHostedService()); } } From ca646522f8007fee2db6177308e579476b923b5e Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:25:33 -0400 Subject: [PATCH 22/24] add a line break to the message --- src/csm/Panels/BrowseServersPanel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/csm/Panels/BrowseServersPanel.cs b/src/csm/Panels/BrowseServersPanel.cs index 779f192b..ec6afb3d 100644 --- a/src/csm/Panels/BrowseServersPanel.cs +++ b/src/csm/Panels/BrowseServersPanel.cs @@ -82,7 +82,7 @@ public override void Update() else if (_awaitingResponse && DateTime.Now.Subtract(_lastRequestSent) >= ResponseTimeout) { _awaitingResponse = false; - _statusLabel.text = "Failed to reach the API server. Check your CSM API Server settings."; + _statusLabel.text = "Failed to reach the API server.\nCheck your CSM API Server settings."; _statusLabel.isVisible = true; } } From fc0508fb2751821ba8ab3dc4823f4219a7d307c3 Mon Sep 17 00:00:00 2001 From: vh84 <10472668+vrnhgd@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:29:26 -0400 Subject: [PATCH 23/24] remove references to Address --- .../Commands/Data/ApiServer/PublicServerEntry.cs | 14 ++++---------- .../Handler/ApiServer/ServerListRequestHandler.cs | 1 - 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs b/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs index 71e1fe9f..2f63f11d 100644 --- a/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs +++ b/src/gs/Commands/Data/ApiServer/PublicServerEntry.cs @@ -4,7 +4,8 @@ namespace CSM.GS.Commands.Data.ApiServer { /// /// A single entry in the public server list. Only contains information - /// safe to expose publicly (no token, no internal/external IP distinction). + /// safe to expose publicly (no internal/external IP distinction - joining + /// goes through the NAT-relay token, same as a Steam friend-invite join). /// [ProtoContract] public class PublicServerEntry @@ -22,15 +23,8 @@ public class PublicServerEntry public bool HasPassword { get; set; } /// - /// The address (ip:port) that a client should connect to in order to join. - /// - [ProtoMember(5)] - public string Address { get; set; } - - /// - /// The server's NAT-relay token, usable as a fallback join method (via - /// "token:" prefixed address in JoinGamePanel) when the direct address - /// isn't reachable (e.g. the host is behind NAT without port forwarding). + /// The server's NAT-relay token, used to join via JoinGamePanel.JoinByToken - + /// the same mechanism used for Steam friend-invite joins. /// [ProtoMember(6)] public string ServerToken { get; set; } diff --git a/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs b/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs index aa295f86..af30e169 100644 --- a/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs +++ b/src/gs/Commands/Handler/ApiServer/ServerListRequestHandler.cs @@ -13,7 +13,6 @@ protected override void Handle(ServerListRequestCommand command) CurrentPlayers = server.CurrentPlayers, MaxPlayers = server.MaxPlayers, HasPassword = server.HasPassword, - Address = $"{server.ExternalAddress.Address}:{server.ExternalAddress.Port}", ServerToken = server.Token }).ToArray(); From b431ad1aed8b0702dfd5a4dbdebc4bd4717c1926 Mon Sep 17 00:00:00 2001 From: vrnhgd <10472668+vrnhgd@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:53:15 -0400 Subject: [PATCH 24/24] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updating UI and reverting changes to HTTP port Co-authored-by: Nico Borgsmüller --- src/csm/Panels/JoinGamePanel.cs | 4 ++-- src/csm/Settings.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/csm/Panels/JoinGamePanel.cs b/src/csm/Panels/JoinGamePanel.cs index 73d50bbc..00804217 100644 --- a/src/csm/Panels/JoinGamePanel.cs +++ b/src/csm/Panels/JoinGamePanel.cs @@ -48,7 +48,7 @@ public override void Start() color = new Color32(110, 110, 110, 255); width = 360; - height = 620; + height = 655; relativePosition = PanelManager.GetCenterPosition(this); // Title Label @@ -109,7 +109,7 @@ public override void Start() }; // Browse public servers - _browseServersButton = this.CreateButton("Browse Public Servers", new Vector2(10, -580)); + _browseServersButton = this.CreateButton("Browse Public Servers", new Vector2(10, -585)); _browseServersButton.eventClick += (component, param) => { isVisible = false; diff --git a/src/csm/Settings.cs b/src/csm/Settings.cs index 487f450a..77f2653e 100644 --- a/src/csm/Settings.cs +++ b/src/csm/Settings.cs @@ -19,7 +19,7 @@ public Settings() private const string DefaultLastSeenReleaseNotes = "0.0"; private const string DefaultApiServer = "api.citiesskylinesmultiplayer.com"; private const int DefaultApiServerPort = 4240; - private const int DefaultApiServerHttpPort = 4241; + private const int DefaultApiServerHttpPort = 80; public readonly SavedBool DebugLogging = new SavedBool(nameof(DebugLogging), SettingsFile, DefaultDebugLogging, true);