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
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);