diff --git a/CMakeLists.txt b/CMakeLists.txt
index 88bcd2864..fd16546cd 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -400,6 +400,23 @@ add_custom_target(test
 	DEPENDS simutrans
 )
 
+# Stand-alone unit test for the recent-server history (non-graphical logic).
+# Excluded from the default build; build/run explicitly with:
+#   cmake --build build --target run_test_recent_servers
+add_executable(test_recent_servers EXCLUDE_FROM_ALL
+	tests/recent_server_list_test.cc
+	src/simutrans/network/recent_server_list.cc
+	src/simutrans/utils/csv.cc
+	src/simutrans/utils/cbuffer.cc
+	src/simutrans/utils/simstring.cc
+)
+target_include_directories(test_recent_servers PRIVATE src/simutrans)
+
+add_custom_target(run_test_recent_servers
+	COMMAND $<TARGET_FILE:test_recent_servers>
+	DEPENDS test_recent_servers
+)
+
 
 #
 # Installation
diff --git a/cmake/SimutransSourceList.cmake b/cmake/SimutransSourceList.cmake
index 33d3044db..b96f131c7 100644
--- a/cmake/SimutransSourceList.cmake
+++ b/cmake/SimutransSourceList.cmake
@@ -218,6 +218,8 @@ target_sources(simutrans PRIVATE
 		src/simutrans/network/network_packet.cc
 		src/simutrans/network/network_socket_list.cc
 		src/simutrans/network/pakset_info.cc
+		src/simutrans/network/recent_server_list.cc
+		src/simutrans/network/recent_server_list_file.cc
 		src/simutrans/obj/baum.cc
 		src/simutrans/obj/bruecke.cc
 		src/simutrans/obj/crossing.cc
diff --git a/simutrans/text/en.tab b/simutrans/text/en.tab
index 0e8333d4b..7f0bb59a5 100644
--- a/simutrans/text/en.tab
+++ b/simutrans/text/en.tab
@@ -923,6 +923,8 @@ isometric map
 Isometric view
 join game
 Play Online
+Recent servers
+Recent servers
 Kann Spielstand\nnicht laden.\n
 Cannot load saved game!
 Kann Spielstand\nnicht speichern.\n
diff --git a/src/simutrans/gui/server_frame.cc b/src/simutrans/gui/server_frame.cc
index c69eb67fb..3e9e4edd7 100644
--- a/src/simutrans/gui/server_frame.cc
+++ b/src/simutrans/gui/server_frame.cc
@@ -19,6 +19,7 @@
 #include "../network/network_cmp_pakset.h"
 #include "../network/network_file_transfer.h"
 #include "../network/pakset_info.h"
+#include "../network/recent_server_list.h"
 #include "../player/simplay.h"
 #include "../simcolor.h"
 #include "../simversion.h"
@@ -32,6 +33,27 @@
 static char nick_buf[256];
 char server_frame_t::newserver_name[2048] = "";
 
+
+/**
+ * One entry of the recent-servers combo box. Displays and remembers the plain
+ * connection address (owns its own copy, so it is safe against the history
+ * changing underneath the combo).
+ */
+class recent_server_item_t : public gui_scrolled_list_t::const_text_scrollitem_t
+{
+private:
+	cbuffer_t serveraddress;
+public:
+	recent_server_item_t(const char *addr) :
+		gui_scrolled_list_t::const_text_scrollitem_t(NULL, SYSCOL_TEXT)
+	{
+		serveraddress.append(addr);
+	}
+	char const *get_text() const OVERRIDE { return serveraddress.get_str(); }
+	char const *get_address() const { return serveraddress.get_str(); }
+};
+
+
 class gui_minimap_t : public gui_component_t
 {
 	gameinfo_t *gi;
@@ -111,6 +133,16 @@ server_frame_t::server_frame_t() :
 		addinput.add_listener( this );
 		add_component( &addinput, 3 );
 
+		// Recent servers: local history of servers connected to by manual entry.
+		// Selecting one fills the address field above; the list is loaded
+		// transparently here and saved on a successful manual join.
+		new_component<gui_label_t>("Recent servers");
+		recent_servers.set_unsorted(); // keep most-recently-used order
+		recent_servers.add_listener( this );
+		add_component( &recent_servers, 2 );
+
+		update_recent_servers();
+
 		new_component_span<gui_divider_t>(3);
 	}
 
@@ -409,6 +441,18 @@ bool server_frame_t::update_serverlist ()
 }
 
 
+void server_frame_t::update_recent_servers ()
+{
+	recent_server_list_t &list = recent_server_list_t::get_instance();
+	recent_servers.clear_elements();
+	for(  uint32 i = 0;  i < list.get_count();  i++  ) {
+		recent_servers.new_component<recent_server_item_t>( list.get(i) );
+	}
+	recent_servers.set_selection( -1 );
+	set_dirty();
+}
+
+
 bool server_frame_t::infowin_event (const event_t *ev)
 {
 	return gui_frame_t::infowin_event( ev );
@@ -472,6 +516,21 @@ bool server_frame_t::action_triggered (gui_action_creator_t *comp, value_t p)
 			serverlist.set_selection( -1 );
 		}
 	}
+	else if (  &recent_servers == comp  ) {
+		// Selecting a remembered server copies its address into the manual
+		// connection field (it stays editable there). As with any manual entry,
+		// an explicit Query is required before Join.
+		if(  recent_servers.get_selection() >= 0  ) {
+			recent_server_item_t *item = static_cast<recent_server_item_t*>( recent_servers.get_selected_item() );
+			if(  item  ) {
+				tstrncpy( newserver_name, item->get_address(), lengthof( newserver_name ) );
+				addinput.set_text( newserver_name, sizeof( newserver_name ) );
+				custom_valid = false;
+				join.disable();
+				serverlist.set_selection( -1 );
+			}
+		}
+	}
 	else if (  &show_mismatched == comp  ) {
 		show_mismatched.pressed ^= 1;
 		serverlist.clear_elements();
@@ -508,12 +567,17 @@ bool server_frame_t::action_triggered (gui_action_creator_t *comp, value_t p)
 		// Prefer serverlist entry if one is selected
 		if (  serverlist.get_selection() >= 0  ) {
 			server = ((server_scrollitem_t*)serverlist.get_selected_item())->get_dns();
+			// picked from the public list -> not a manual entry, do not record
+			recent_server_list_t::set_pending_manual_connection(NULL);
 		}
 		// If we have a valid custom server entry, connect to that
 		else if (  custom_valid  ) {
 			server = newserver_name;
+			// remember this manual address if (and only if) the join succeeds
+			recent_server_list_t::set_pending_manual_connection(server);
 		}
 		else {
+			recent_server_list_t::set_pending_manual_connection(NULL);
 			dbg->error( "server_frame_t::action_triggered()", "join pressed without valid selection or custom server entry" );
 			join.disable();
 		}
diff --git a/src/simutrans/gui/server_frame.h b/src/simutrans/gui/server_frame.h
index f4db7311d..831f7d636 100644
--- a/src/simutrans/gui/server_frame.h
+++ b/src/simutrans/gui/server_frame.h
@@ -33,6 +33,10 @@ private:
 	gui_textinput_t addinput, nick;
 	static char newserver_name[2048];
 
+	// Local history of manually-connected servers (see recent_server_list_t).
+	// Loaded transparently when the dialog opens; saved on a successful join.
+	gui_combobox_t recent_servers;
+
 	gui_scrolled_list_t serverlist;
 	button_t add, join, find_mismatch, show_motd;
 	button_t show_mismatched, show_offline;
@@ -94,6 +98,11 @@ private:
 	 */
 	bool update_serverlist ();
 
+	/**
+	 * Rebuild the recent-servers combo from the shared history
+	 */
+	void update_recent_servers ();
+
 public:
 	server_frame_t();
 
diff --git a/src/simutrans/network/recent_server_list.cc b/src/simutrans/network/recent_server_list.cc
new file mode 100644
index 000000000..a01b714f8
--- /dev/null
+++ b/src/simutrans/network/recent_server_list.cc
@@ -0,0 +1,278 @@
+/*
+ * This file is part of the Simutrans project under the Artistic License.
+ * (see LICENSE.txt)
+ */
+
+// Pure logic + CSV (de)serialization for the recent-server history.
+//
+// Everything in this translation unit is deliberately free of world, GUI and
+// operating-system dependencies (no env_t, no simsys) so it can be linked into a
+// small stand-alone unit test. The file-system wrappers live in
+// recent_server_list_file.cc.
+
+#include <stdlib.h>
+#include <string.h>
+#include <errno.h>
+
+#include "recent_server_list.h"
+
+#include "../utils/cbuffer.h"
+#include "../utils/csv.h"
+
+
+// On-disk header magic. The full format is a versioned CSV file, one record per
+// physical line, UTF-8:
+//
+//   simutrans recent servers,1
+//   address1
+//   address2
+//
+// A line that fails to parse is skipped without aborting the rest of the file.
+static const char *RECENT_SERVER_MAGIC = "simutrans recent servers";
+static std::string pending_manual_address;
+
+
+static bool is_ascii_space(char c)
+{
+	const unsigned char u = (unsigned char)c;
+	return u == ' ' || u == '\t' || u == '\r' || u == '\n' || u == '\f' || u == '\v';
+}
+
+
+std::string recent_server_list_t::normalize_address(const char *raw)
+{
+	if(  raw == NULL  ) {
+		return std::string();
+	}
+	const char *start = raw;
+	const char *end   = raw + strlen(raw);
+	while(  start < end  &&  is_ascii_space(*start)  ) {
+		start++;
+	}
+	while(  end > start  &&  is_ascii_space(end[-1])  ) {
+		end--;
+	}
+	return std::string(start, end - start);
+}
+
+
+bool recent_server_list_t::is_valid_address(const std::string &address)
+{
+	if(  address.empty()  ||  address.size() > MAX_ADDRESS_LEN  ) {
+		return false;
+	}
+	// No control characters (this also rejects any stray newline/tab that could
+	// break the line-based format).
+	for(  size_t i = 0;  i < address.size();  i++  ) {
+		if(  (unsigned char)address[i] < 0x20  ) {
+			return false;
+		}
+	}
+	return true;
+}
+
+
+void recent_server_list_t::set_pending_manual_connection(const char *address)
+{
+	pending_manual_address = normalize_address(address);
+	if(  !is_valid_address(pending_manual_address)  ) {
+		pending_manual_address.clear();
+	}
+}
+
+
+bool recent_server_list_t::consume_pending_manual_connection(const char *address, bool success)
+{
+	const std::string connected_address = normalize_address(address);
+	const bool record = success  &&  !pending_manual_address.empty()  &&  connected_address == pending_manual_address;
+	pending_manual_address.clear();
+	return record;
+}
+
+
+void recent_server_list_t::replace_with(const recent_server_list_t &other)
+{
+	entries.clear();
+	for(  uint32 i = 0;  i < other.entries.get_count();  i++  ) {
+		entries.append(other.entries[i]);
+	}
+}
+
+
+sint32 recent_server_list_t::index_of(const std::string &address) const
+{
+	for(  uint32 i = 0;  i < entries.get_count();  i++  ) {
+		if(  entries[i] == address  ) {
+			return (sint32)i;
+		}
+	}
+	return -1;
+}
+
+
+bool recent_server_list_t::add(const char *address)
+{
+	const std::string addr = normalize_address(address);
+	if(  !is_valid_address(addr)  ) {
+		return false;
+	}
+
+	const sint32 idx = index_of(addr);
+	if(  idx == 0  ) {
+		return false; // already most-recent, nothing changes
+	}
+	if(  idx > 0  ) {
+		entries.remove_at((uint32)idx);
+	}
+	entries.insert_at(0, addr);
+
+	// enforce the cap (drop the oldest)
+	while(  entries.get_count() > MAX_ENTRIES  ) {
+		entries.remove_at(entries.get_count() - 1);
+	}
+	return true;
+}
+
+
+void recent_server_list_t::merge_entry(vector_tpl<std::string> &out, const std::string &address)
+{
+	// keep prior priority / file order; a repeated address collapses to one entry
+	for(  uint32 i = 0;  i < out.get_count();  i++  ) {
+		if(  out[i] == address  ) {
+			return;
+		}
+	}
+	out.append(address);
+}
+
+
+bool recent_server_list_t::parse_buffer(const char *data, size_t len, vector_tpl<std::string> &out)
+{
+	bool header_seen = false;
+
+	size_t i = 0;
+	while(  i < len  ) {
+		// isolate one physical line [i, eol)
+		size_t eol = i;
+		while(  eol < len  &&  data[eol] != '\n'  ) {
+			eol++;
+		}
+		size_t line_end = eol;
+		// strip a trailing '\r' (CRLF files)
+		if(  line_end > i  &&  data[line_end - 1] == '\r'  ) {
+			line_end--;
+		}
+
+		std::string line(data + i, line_end - i);
+		i = eol + 1; // advance past the newline for the next iteration
+
+		if(  line.empty()  ) {
+			continue;
+		}
+
+		// each record is exactly one line -> parse it in isolation so a damaged
+		// line can never swallow the remaining ones
+		CSV_t csv(line.c_str());
+		cbuffer_t f0;
+		const int r0 = csv.get_next_field(f0);
+		if(  r0 < 0  ) {
+			continue; // corrupt line, skip
+		}
+
+		if(  !header_seen  ) {
+			header_seen = true;
+			if(  normalize_address(f0.get_str()) != RECENT_SERVER_MAGIC  ) {
+				return false;
+			}
+			cbuffer_t f1;
+			if(  csv.get_next_field(f1) < 0  ) {
+				return false;
+			}
+			const std::string version_text = normalize_address(f1.get_str());
+			char *end = NULL;
+			errno = 0;
+			const long version = strtol(version_text.c_str(), &end, 10);
+			if(  errno != 0  ||  end == version_text.c_str()  ||  *end != '\0'  ||  version != FILE_VERSION  ) {
+				return false;
+			}
+			continue;
+		}
+
+		const std::string addr = normalize_address(f0.get_str());
+		if(  !is_valid_address(addr)  ) {
+			continue;
+		}
+		merge_entry(out, addr);
+	}
+
+	return header_seen;
+}
+
+
+void recent_server_list_t::to_csv(cbuffer_t &out) const
+{
+	CSV_t csv;
+	csv.add_field(RECENT_SERVER_MAGIC);
+	csv.add_field((int)FILE_VERSION);
+	csv.new_line();
+	for(  uint32 i = 0;  i < entries.get_count();  i++  ) {
+		csv.add_field(entries[i].c_str());
+		csv.new_line();
+	}
+	out.append(csv.get_str());
+}
+
+
+static bool read_all(FILE *f, std::string &data)
+{
+	char tmp[4096];
+	size_t n;
+	while(  (n = fread(tmp, 1, sizeof(tmp), f)) > 0  ) {
+		if(  data.size() + n > recent_server_list_t::MAX_FILE_SIZE  ) {
+			return false;
+		}
+		data.append(tmp, n);
+	}
+	return ferror(f) == 0;
+}
+
+
+bool recent_server_list_t::load_from_stream(FILE *f)
+{
+	if(  f == NULL  ) {
+		return false;
+	}
+	std::string data;
+	if(  !read_all(f, data)  ) {
+		return false;
+	}
+
+	vector_tpl<std::string> parsed;
+	if(  !parse_buffer(data.c_str(), data.size(), parsed)  ) {
+		return false; // foreign/empty file: keep the current list untouched
+	}
+	while(  parsed.get_count() > MAX_ENTRIES  ) {
+		parsed.remove_at(parsed.get_count() - 1);
+	}
+
+	entries.clear();
+	for(  uint32 i = 0;  i < parsed.get_count();  i++  ) {
+		entries.append(parsed[i]);
+	}
+	return true;
+}
+
+
+bool recent_server_list_t::save_to_stream(FILE *f) const
+{
+	if(  f == NULL  ) {
+		return false;
+	}
+	cbuffer_t buf;
+	to_csv(buf);
+	const size_t len = (size_t)buf.len();
+	if(  len > 0  &&  fwrite(buf.get_str(), 1, len, f) != len  ) {
+		return false;
+	}
+	return ferror(f) == 0;
+}
diff --git a/src/simutrans/network/recent_server_list.h b/src/simutrans/network/recent_server_list.h
new file mode 100644
index 000000000..bba5364a6
--- /dev/null
+++ b/src/simutrans/network/recent_server_list.h
@@ -0,0 +1,113 @@
+/*
+ * This file is part of the Simutrans project under the Artistic License.
+ * (see LICENSE.txt)
+ */
+
+#ifndef NETWORK_RECENT_SERVER_LIST_H
+#define NETWORK_RECENT_SERVER_LIST_H
+
+
+#include <stdio.h>
+#include <string>
+
+#include "../simtypes.h"
+#include "../tpl/vector_tpl.h"
+
+class cbuffer_t;
+
+
+/**
+ * Persistent, most-recently-used history of the addresses a player joined by
+ * typing them into the multiplayer dialog.
+ *
+ * Design notes:
+ * - The list is capped (MAX_ENTRIES), most-recently-used first, deduplicated by
+ *   exact normalized address. It stores only the connection address as the user
+ *   typed it (no "net:" prefix) - never a password, nickname or game data.
+ * - The pure logic (add) and the CSV (de)serialization work on strings and
+ *   @c FILE* only, so they are unit-testable without the rest of the engine (no
+ *   world, no GUI). The @c dr_fopen based file wrappers are a thin layer on top
+ *   (recent_server_list_file.cc).
+ * - The on-disk format is a small versioned CSV file (see recent_server_list.cc)
+ *   under @c env_t::user_dir. It is loaded transparently when the multiplayer
+ *   dialog opens and saved when a manual connection succeeds - there is no
+ *   management dialog.
+ */
+class recent_server_list_t
+{
+public:
+	static const uint32 MAX_ENTRIES     = 10;   ///< keep at most this many servers
+	static const size_t MAX_ADDRESS_LEN = 256;  ///< reject longer addresses
+	static const size_t MAX_FILE_SIZE   = 64 * 1024; ///< reject unexpectedly large files
+	static const int    FILE_VERSION    = 1;    ///< current on-disk format version
+
+private:
+	vector_tpl<std::string> entries; ///< front == most recently used
+
+	sint32 index_of(const std::string &address) const;
+
+	/// Parse a whole CSV buffer into @p out (deduplicated, in file order). Returns
+	/// true when the header was recognized, false otherwise (unknown/foreign file).
+	static bool parse_buffer(const char *data, size_t len, vector_tpl<std::string> &out);
+
+	/// Append @p address to @p out unless it is already present (load dedup).
+	static void merge_entry(vector_tpl<std::string> &out, const std::string &address);
+
+public:
+	recent_server_list_t() {}
+
+	/// Process-wide instance, lazily loaded from the user directory on first use.
+	static recent_server_list_t &get_instance();
+
+	/// Mark or clear the address of a connection started from the manual-entry UI.
+	static void set_pending_manual_connection(const char *address);
+
+	/// Consume a connection result and report whether it matches the pending
+	/// manual attempt. Exposed for the non-graphical integration test.
+	static bool consume_pending_manual_connection(const char *address, bool success);
+
+	/// Complete a connection attempt. Only a matching pending manual address is
+	/// recorded, and every result clears the pending state.
+	static void notify_connection_result(const char *address, bool success);
+
+	// --- read access ---------------------------------------------------------
+	uint32 get_count() const { return entries.get_count(); }
+	const char *get(uint32 i) const { return entries[i].c_str(); }
+	bool empty() const { return entries.empty(); }
+	void clear() { entries.clear(); }
+	void replace_with(const recent_server_list_t &other);
+
+	// --- mutation (in memory, does NOT persist) ------------------------------
+
+	/// Insert @p address at the front, or promote it if already present. Invalid/
+	/// empty addresses are ignored. Returns true when the list changed.
+	bool add(const char *address);
+
+	// --- normalization / validation (static, testable) -----------------------
+	static std::string normalize_address(const char *raw);
+	static bool is_valid_address(const std::string &address);
+
+	// --- CSV (de)serialization (file-system independent) ---------------------
+
+	/// Serialize the whole list as versioned CSV into @p out.
+	void to_csv(cbuffer_t &out) const;
+
+	/// Replace the contents from a CSV @c FILE*. Returns true on a recognized
+	/// header (even with zero valid entries); false leaves the list untouched.
+	bool load_from_stream(FILE *f);
+
+	/// Write the list as CSV to @p f. Returns true on success.
+	bool save_to_stream(FILE *f) const;
+
+	// --- file persistence (uses dr_fopen / user dir) -------------------------
+
+	/// Load from the user-directory file. A missing/empty/foreign file is not an
+	/// error: the list simply stays as it is and the function returns false.
+	bool load();
+
+	/// Persist to the user-directory file, writing a temp file first and renaming
+	/// it into place to avoid corruption. Returns true on success.
+	bool save() const;
+};
+
+#endif
diff --git a/src/simutrans/network/recent_server_list_file.cc b/src/simutrans/network/recent_server_list_file.cc
new file mode 100644
index 000000000..1763bd143
--- /dev/null
+++ b/src/simutrans/network/recent_server_list_file.cc
@@ -0,0 +1,92 @@
+/*
+ * This file is part of the Simutrans project under the Artistic License.
+ * (see LICENSE.txt)
+ */
+
+// File-system and process-wide-instance layer for the recent-server history.
+// Kept apart from recent_server_list.cc so the pure logic stays testable without
+// pulling in env_t / simsys.
+
+#include <string>
+
+#include "recent_server_list.h"
+
+#include "../dataobj/environment.h"
+#include "../sys/simsys.h"
+
+
+static const char *RECENT_SERVER_FILE = "recent_servers.csv";
+
+
+static std::string user_dir_path()
+{
+	return std::string(env_t::user_dir) + RECENT_SERVER_FILE;
+}
+
+
+recent_server_list_t &recent_server_list_t::get_instance()
+{
+	static recent_server_list_t instance;
+	static bool loaded = false;
+	if(  !loaded  ) {
+		loaded = true; // set first: a failed load must not retrigger on every call
+		instance.load();
+	}
+	return instance;
+}
+
+
+void recent_server_list_t::notify_connection_result(const char *address, bool success)
+{
+	if(  !consume_pending_manual_connection(address, success)  ) {
+		return;
+	}
+
+	recent_server_list_t &list = get_instance();
+	const recent_server_list_t previous = list;
+	if(  list.add(address)  &&  !list.save()  ) {
+		list.replace_with(previous);
+	}
+}
+
+
+bool recent_server_list_t::load()
+{
+	const std::string path = user_dir_path();
+	FILE *f = dr_fopen(path.c_str(), "rb");
+	if(  f == NULL  ) {
+		// no history yet (or unreadable): not an error, just start empty
+		return false;
+	}
+	const bool ok = load_from_stream(f);
+	fclose(f);
+	return ok;
+}
+
+
+bool recent_server_list_t::save() const
+{
+	const std::string path = user_dir_path();
+	const std::string tmp  = path + ".tmp";
+
+	// write to a temp file first, then swap it in, so a crash mid-write cannot
+	// corrupt an existing history
+	FILE *f = dr_fopen(tmp.c_str(), "wb");
+	if(  f == NULL  ) {
+		return false;
+	}
+	bool ok = save_to_stream(f);
+	if(  fclose(f) != 0  ) {
+		ok = false;
+	}
+	if(  !ok  ) {
+		dr_remove(tmp.c_str());
+		return false;
+	}
+
+	if(  dr_rename(tmp.c_str(), path.c_str()) != 0  ) {
+		dr_remove(tmp.c_str());
+		return false;
+	}
+	return true;
+}
diff --git a/src/simutrans/world/simworld.cc b/src/simutrans/world/simworld.cc
index e1c354b44..9ade9b390 100644
--- a/src/simutrans/world/simworld.cc
+++ b/src/simutrans/world/simworld.cc
@@ -77,6 +77,7 @@
 #include "../network/network_file_transfer.h"
 #include "../network/network_socket_list.h"
 #include "../network/network_cmd_ingame.h"
+#include "../network/recent_server_list.h"
 
 #include "../dataobj/height_map_loader.h"
 #include "../dataobj/ribi.h"
@@ -3830,6 +3831,7 @@ bool karte_t::load(const char *filename)
 		dr_chdir( env_t::user_dir );
 		const char *err = network_connect(filename+4, this);
 		if(err) {
+			recent_server_list_t::notify_connection_result(filename + 4, false);
 			create_win( new news_img(err), w_info, magic_none );
 			gfx->set_show_load_cursor(false);
 			step_mode = NORMAL;
@@ -3842,6 +3844,7 @@ bool karte_t::load(const char *filename)
 			if(  !restore_player_nr  ) {
 				last_network_game = filename;
 			}
+			recent_server_list_t::notify_connection_result(filename + 4, true);
 		}
 	}
 	else {
diff --git a/tests/recent_server_list_test.cc b/tests/recent_server_list_test.cc
new file mode 100644
index 000000000..cba4ccdbb
--- /dev/null
+++ b/tests/recent_server_list_test.cc
@@ -0,0 +1,382 @@
+/*
+ * This file is part of the Simutrans project under the Artistic License.
+ * (see LICENSE.txt)
+ */
+
+// Stand-alone unit test for recent_server_list_t (the non-graphical part of the
+// recent-server history feature). It exercises the pure logic + CSV
+// serialization only, using standard C FILE* streams, so it needs neither the
+// world, the GUI nor the operating-system file layer.
+//
+// Build/run: see the `run_test_recent_servers` target in CMakeLists.txt. To
+// compile by hand, g++ over recent_server_list_test.cc plus recent_server_list.cc,
+// csv.cc, cbuffer.cc and simstring.cc with -I src/simutrans.
+
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <string>
+
+#include "network/recent_server_list.h"
+#include "simdebug.h"
+#include "utils/log.h"
+
+// cbuffer.cc / vector_tpl reference the global logger only on error/format-check
+// paths this test never triggers. A null pointer plus minimal method stubs are
+// enough to satisfy the linker without pulling in the whole logging subsystem.
+log_t *dbg = NULL;
+void log_t::warning(const char *, const char *, ...) {}
+void log_t::fatal(const char *, const char *, ...) { abort(); }
+
+
+static int g_checks = 0;
+static int g_failed = 0;
+
+static void check(bool cond, const char *name)
+{
+	g_checks++;
+	if(  !cond  ) {
+		g_failed++;
+		printf("  FAIL: %s\n", name);
+	}
+}
+
+// Build a temporary stream pre-filled with `content`, rewound for reading.
+static FILE *stream_with(const char *content)
+{
+	FILE *f = tmpfile();
+	if(  content  &&  *content  ) {
+		fwrite(content, 1, strlen(content), f);
+	}
+	rewind(f);
+	return f;
+}
+
+
+// 1. A fresh list is empty.
+static void test_empty()
+{
+	recent_server_list_t l;
+	check(l.get_count() == 0, "1 empty: count 0");
+	check(l.empty(), "1 empty: empty()");
+}
+
+// 2. Adding an address stores it.
+static void test_add()
+{
+	recent_server_list_t l;
+	check(l.add("server.example:13353"), "2 add: returns true");
+	check(l.get_count() == 1, "2 add: count 1");
+	check(std::string(l.get(0)) == "server.example:13353", "2 add: address stored");
+}
+
+// 3. Outer whitespace is trimmed, inner content preserved.
+static void test_trim()
+{
+	recent_server_list_t l;
+	l.add("  host.example  ");
+	check(l.get_count() == 1, "3 trim: added");
+	check(std::string(l.get(0)) == "host.example", "3 trim: trimmed");
+}
+
+// 4. An empty / whitespace-only address is rejected.
+static void test_reject_empty()
+{
+	recent_server_list_t l;
+	check(!l.add("   "), "4 reject: whitespace false");
+	check(!l.add(""), "4 reject: empty string false");
+	check(!l.add(NULL), "4 reject: NULL false");
+	check(l.get_count() == 0, "4 reject: nothing stored");
+}
+
+// 5. A control character in the address is rejected (protects the line format).
+static void test_reject_control()
+{
+	recent_server_list_t l;
+	check(!l.add("host\nevil"), "5 control: newline rejected");
+	check(!l.add("host\tevil"), "5 control: tab rejected");
+	check(l.get_count() == 0, "5 control: nothing stored");
+}
+
+// 6. An IPv6 address with a port is stored verbatim.
+static void test_ipv6()
+{
+	recent_server_list_t l;
+	const char *addr = "[2001:db8::1]:13353";
+	check(l.add(addr), "6 ipv6: accepted");
+	check(std::string(l.get(0)) == addr, "6 ipv6: unchanged");
+}
+
+// 7. Re-adding an existing address moves it to the front; re-adding the current
+//    front is a no-op.
+static void test_duplicate_to_front()
+{
+	recent_server_list_t l;
+	l.add("A");
+	l.add("B");
+	check(l.add("A"), "7 dup: A re-added returns changed");
+	check(l.get_count() == 2, "7 dup: no growth");
+	check(std::string(l.get(0)) == "A", "7 dup: A promoted");
+	check(std::string(l.get(1)) == "B", "7 dup: B second");
+	check(!l.add("A"), "7 dup: re-adding current front is a no-op");
+}
+
+// 8. Exactly ten entries fit.
+static void test_cap_exact()
+{
+	recent_server_list_t l;
+	for(  int i = 0;  i < 10;  i++  ) {
+		char a[8];
+		sprintf(a, "h%d", i);
+		l.add(a);
+	}
+	check(l.get_count() == 10, "8 cap: exactly 10");
+}
+
+// 9. The eleventh entry drops the oldest.
+static void test_cap_overflow()
+{
+	recent_server_list_t l;
+	for(  int i = 0;  i < 11;  i++  ) {
+		char a[8];
+		sprintf(a, "h%d", i);
+		l.add(a);
+	}
+	check(l.get_count() == 10, "9 overflow: capped at 10");
+	check(std::string(l.get(0)) == "h10", "9 overflow: newest in front");
+	// h0 was the oldest and must be gone
+	bool has_oldest = false;
+	for(  uint32 i = 0;  i < l.get_count();  i++  ) {
+		if(  std::string(l.get(i)) == "h0"  ) { has_oldest = true; }
+	}
+	check(!has_oldest, "9 overflow: oldest dropped");
+}
+
+// 10. Save then load yields an equivalent list (including CSV-escaped addresses).
+static void test_save_load_roundtrip()
+{
+	recent_server_list_t l;
+	l.add("host.one:13353");
+	l.add("[2001:db8::1]:13353");
+	l.add("weird,addr \"quoted\"");
+
+	FILE *f = tmpfile();
+	check(l.save_to_stream(f), "10 roundtrip: saved");
+	rewind(f);
+
+	recent_server_list_t l2;
+	check(l2.load_from_stream(f), "10 roundtrip: loaded");
+	fclose(f);
+
+	check(l2.get_count() == l.get_count(), "10 roundtrip: same count");
+	bool same = l2.get_count() == l.get_count();
+	for(  uint32 i = 0;  same  &&  i < l.get_count();  i++  ) {
+		same = std::string(l2.get(i)) == std::string(l.get(i));
+	}
+	check(same, "10 roundtrip: entries equal (order + escaping)");
+}
+
+// 11. A non-existent file is not an error and leaves the list untouched.
+static void test_missing_file()
+{
+	recent_server_list_t l;
+	l.add("keep.me");
+	FILE *f = fopen("this_file_should_not_exist_12345.csv", "rb");
+	check(f == NULL, "11 missing: fopen returns NULL");
+	check(!l.load_from_stream(f), "11 missing: load false on NULL");
+	check(l.get_count() == 1 && std::string(l.get(0)) == "keep.me", "11 missing: list intact");
+}
+
+// 12. An empty file loads as "nothing recognized" and keeps the list.
+static void test_empty_file()
+{
+	recent_server_list_t l;
+	l.add("keep.me");
+	FILE *f = stream_with("");
+	check(!l.load_from_stream(f), "12 empty file: load false");
+	fclose(f);
+	check(l.get_count() == 1, "12 empty file: list intact");
+}
+
+// 13. Unknown headers and versions are rejected without changing the list.
+static void test_unknown_header_version()
+{
+	{
+		recent_server_list_t l;
+		l.add("keep.me");
+		FILE *f = stream_with("some other file,1\nhost\n");
+		check(!l.load_from_stream(f), "13a foreign header: refused");
+		fclose(f);
+		check(l.get_count() == 1 && std::string(l.get(0)) == "keep.me", "13a foreign header: intact");
+	}
+	{
+		recent_server_list_t l;
+		l.add("keep.me");
+		FILE *f = stream_with("simutrans recent servers,999\nhost.future\n");
+		check(!l.load_from_stream(f), "13b newer version: refused");
+		fclose(f);
+		check(l.get_count() == 1 && std::string(l.get(0)) == "keep.me", "13b newer version: intact");
+	}
+	{
+		recent_server_list_t l;
+		l.add("keep.me");
+		FILE *f = stream_with("simutrans recent servers\nhost\n");
+		check(!l.load_from_stream(f), "13c missing version: refused");
+		fclose(f);
+		check(l.get_count() == 1 && std::string(l.get(0)) == "keep.me", "13c missing version: intact");
+	}
+}
+
+// 14. A damaged line is skipped; the surrounding valid lines survive.
+static void test_damaged_lines()
+{
+	recent_server_list_t l;
+	// second data line has an unterminated quote -> corrupt, must be skipped
+	FILE *f = stream_with(
+		"simutrans recent servers,1\n"
+		"good.one\n"
+		"\"broken.addr,still broken\n"
+		"good.two\n");
+	check(l.load_from_stream(f), "14 damaged: header ok");
+	fclose(f);
+	check(l.get_count() == 2, "14 damaged: two good survive");
+	check(std::string(l.get(0)) == "good.one", "14 damaged: first good");
+	check(std::string(l.get(1)) == "good.two", "14 damaged: second good");
+}
+
+// 15. Loading de-duplicates repeats in the file, keeping the first occurrence.
+static void test_load_duplicates()
+{
+	recent_server_list_t l;
+	FILE *f = stream_with(
+		"simutrans recent servers,1\n"
+		"A\n"
+		"B\n"
+		"A\n"); // duplicate within the file -> collapses
+	check(l.load_from_stream(f), "15 dup: header ok");
+	fclose(f);
+	check(l.get_count() == 2, "15 dup: A,B only");
+	check(std::string(l.get(0)) == "A", "15 dup: A first");
+	check(std::string(l.get(1)) == "B", "15 dup: B second");
+}
+
+// 16. A file with more than the cap keeps only the newest MAX_ENTRIES.
+static void test_load_cap()
+{
+	recent_server_list_t l;
+	std::string input = "simutrans recent servers,1\n";
+	for(  int i = 0;  i < 15;  i++  ) {
+		char line[16];
+		sprintf(line, "h%d\n", i);
+		input += line;
+	}
+	FILE *f = stream_with(input.c_str());
+	check(l.load_from_stream(f), "16 load cap: header ok");
+	fclose(f);
+	check(l.get_count() == recent_server_list_t::MAX_ENTRIES, "16 load cap: trimmed to cap");
+	check(std::string(l.get(0)) == "h0", "16 load cap: file order kept from front");
+}
+
+// 17. Export contains only the header plus one address per line.
+static void test_export_fields()
+{
+	recent_server_list_t l;
+	l.add("host.a");
+	l.add("host.b");
+
+	FILE *f = tmpfile();
+	l.save_to_stream(f);
+	rewind(f);
+
+	char buf[2048];
+	size_t got = fread(buf, 1, sizeof(buf) - 1, f);
+	buf[got] = '\0';
+	fclose(f);
+
+	// header first
+	check(strncmp(buf, "simutrans recent servers,", 25) == 0, "17 export: versioned header");
+
+	// data lines have no top-level comma (single address field)
+	bool ok = true;
+	int line = 0;
+	const char *p = buf;
+	while(  *p  ) {
+		const char *eol = strchr(p, '\n');
+		size_t len = eol ? (size_t)(eol - p) : strlen(p);
+		if(  len > 0  &&  line > 0  ) {
+			for(  size_t i = 0;  i < len;  i++  ) {
+				if(  p[i] == ','  ) { ok = false; }
+			}
+		}
+		line++;
+		if(  !eol  ) { break; }
+		p = eol + 1;
+	}
+	check(ok, "17 export: data lines are a single address field");
+}
+
+// 18. Only a successful result matching a pending manual attempt is accepted.
+static void test_manual_connection_tracking()
+{
+	recent_server_list_t::set_pending_manual_connection("manual.host:13353");
+	check(!recent_server_list_t::consume_pending_manual_connection("manual.host:13353", false),
+		"18 manual tracking: failed connection rejected");
+
+	recent_server_list_t::set_pending_manual_connection("manual.host:13353");
+	check(recent_server_list_t::consume_pending_manual_connection("manual.host:13353", true),
+		"18 manual tracking: matching success accepted");
+
+	recent_server_list_t::set_pending_manual_connection(NULL);
+	check(!recent_server_list_t::consume_pending_manual_connection("public.host:13353", true),
+		"18 manual tracking: public-list success rejected");
+
+	recent_server_list_t::set_pending_manual_connection("manual.host:13353");
+	check(!recent_server_list_t::consume_pending_manual_connection("other.host:13353", true),
+		"18 manual tracking: mismatched address rejected");
+
+	// a consumed pending marker does not survive to a second result
+	recent_server_list_t::set_pending_manual_connection("manual.host:13353");
+	recent_server_list_t::consume_pending_manual_connection("manual.host:13353", true);
+	check(!recent_server_list_t::consume_pending_manual_connection("manual.host:13353", true),
+		"18 manual tracking: marker is one-shot");
+}
+
+// 19. An unexpectedly large file is rejected without changing the list.
+static void test_oversized_file()
+{
+	std::string input = "simutrans recent servers,1\n";
+	input.append(recent_server_list_t::MAX_FILE_SIZE, 'x');
+	FILE *f = stream_with(input.c_str());
+	recent_server_list_t l;
+	l.add("keep.me");
+	check(!l.load_from_stream(f), "19 oversized file: rejected");
+	fclose(f);
+	check(l.get_count() == 1 && std::string(l.get(0)) == "keep.me", "19 oversized file: list intact");
+}
+
+
+int main()
+{
+	test_empty();
+	test_add();
+	test_trim();
+	test_reject_empty();
+	test_reject_control();
+	test_ipv6();
+	test_duplicate_to_front();
+	test_cap_exact();
+	test_cap_overflow();
+	test_save_load_roundtrip();
+	test_missing_file();
+	test_empty_file();
+	test_unknown_header_version();
+	test_damaged_lines();
+	test_load_duplicates();
+	test_load_cap();
+	test_export_fields();
+	test_manual_connection_tracking();
+	test_oversized_file();
+
+	printf("recent_server_list_test: %d checks, %d failed\n", g_checks, g_failed);
+	return g_failed == 0 ? 0 : 1;
+}
