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..cfb995675 100644
--- a/simutrans/text/en.tab
+++ b/simutrans/text/en.tab
@@ -923,6 +923,10 @@ isometric map
 Isometric view
 join game
 Play Online
+Server list
+Server list
+Last Connected to
+Last Connected to
 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..e61e4e78c 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,30 @@
 static char nick_buf[256];
 char server_frame_t::newserver_name[2048] = "";
 
+
+/**
+ * One entry of the last-connected combo box. Shows the announced server name
+ * when it is known and the bare address otherwise, but always connects to the
+ * address. Owns its copies, 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 label;
+	cbuffer_t serveraddress;
+public:
+	recent_server_item_t(const char *text, const char *addr) :
+		gui_scrolled_list_t::const_text_scrollitem_t(NULL, SYSCOL_TEXT)
+	{
+		label.append(text);
+		serveraddress.append(addr);
+	}
+	char const *get_text() const OVERRIDE { return label.get_str(); }
+	char const *get_address() const { return serveraddress.get_str(); }
+};
+
+
 class gui_minimap_t : public gui_component_t
 {
 	gameinfo_t *gi;
@@ -64,7 +89,7 @@ server_frame_t::server_frame_t() :
 	gui_frame_t( translator::translate("Game info") ),
 	gi(welt),
 	custom_valid(false),
-	serverlist( gui_scrolled_list_t::listskin, gui_scrolled_list_t::scrollitem_t::compare ),
+	serverlist( gui_scrolled_list_t::scrollitem_t::compare ),
 	game_text(&buf)
 {
 	map = new gui_minimap_t();
@@ -82,10 +107,23 @@ server_frame_t::server_frame_t() :
 		join.add_listener(this);
 		add_component(&join);
 
-		// Server listing
+		// Server listing. A combobox rather than a list: it keeps the dialog a
+		// fixed height whatever the announce server returns.
+		// Label on its own row and the combo spanning the table, so an empty
+		// combo still gets the full width instead of collapsing to its arrows.
+		new_component_span<gui_label_t>("Server list", 3);
 		serverlist.set_selection( 0 );
-		add_component( &serverlist, 3 );
 		serverlist.add_listener(this);
+		add_component( &serverlist, 3 );
+
+		// Every server a game was actually loaded from, most recent first,
+		// whichever part of the UI started the connection.
+		new_component_span<gui_label_t>("Last Connected to", 3);
+		recent_servers.set_unsorted(); // keep most-recently-used order
+		recent_servers.add_listener( this );
+		add_component( &recent_servers, 3 );
+
+		update_recent_servers();
 
 		// Show mismatched checkbox
 		show_mismatched.init( button_t::square_state | button_t::flexible, "Show mismatched");
@@ -287,6 +325,7 @@ bool server_frame_t::update_serverlist ()
 	// Download game listing from listings server into memory
 	cbuffer_t buf;
 	const char *err = NULL;
+	bool history_changed = false;
 
 	if ((err = network_http_get(ANNOUNCE_SERVER1, ANNOUNCE_LIST_URL, buf))) {
 		dbg->warning("server_frame_t::update_serverlist", "Could not download server list from %s: %s", ANNOUNCE_SERVER1, err);
@@ -386,6 +425,13 @@ bool server_frame_t::update_serverlist ()
 			dbg->message("server_frame_t::update_serverlist", "altdns: %s", serverdns2.get_str());
 		}
 
+		// Teach the history what this server is called and where it is now. A
+		// server that moved to a new IP is followed by name instead of leaving a
+		// dead entry behind, which private servers on a 24h lease need. Done
+		// before the display filters, so a server hidden by a checkbox is still
+		// tracked.
+		history_changed |= recent_server_list_t::get_instance().reconcile( servername.get_str(), serverdns.get_str() );
+
 		// Only show offline servers if the checkbox is set
 		if(  status == 1  ||  show_offline.pressed  ) {
 			PIXVAL color = status == 1 ? SYSCOL_EMPTY : MONEY_MINUS;
@@ -402,6 +448,11 @@ bool server_frame_t::update_serverlist ()
 	serverlist.set_selection( -1 );
 	serverlist.set_size(serverlist.get_size());
 
+	if(  history_changed  ) {
+		recent_server_list_t::get_instance().save();
+		update_recent_servers();
+	}
+
 	set_dirty();
 	resize(scr_size(0, 0));
 
@@ -409,6 +460,39 @@ 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_display(i), list.get_address(i) );
+	}
+	recent_servers.set_selection( -1 );
+	set_dirty();
+}
+
+
+void server_frame_t::query_manual_server ()
+{
+	if(  newserver_name[0] == '\0'  ) {
+		return;
+	}
+	join.disable();
+
+	gfx->set_show_load_cursor(true);
+	const char *err = network_gameinfo( newserver_name, &gi );
+	if(  err == NULL  ) {
+		custom_valid = true;
+		update_info();
+	}
+	else {
+		custom_valid = false;
+		update_error( "Server did not respond!" );
+	}
+	gfx->set_show_load_cursor(false);
+}
+
+
 bool server_frame_t::infowin_event (const event_t *ev)
 {
 	return gui_frame_t::infowin_event( ev );
@@ -419,6 +503,10 @@ bool server_frame_t::action_triggered (gui_action_creator_t *comp, value_t p)
 {
 	// Selection has changed
 	if(  &serverlist == comp  ) {
+		// only one of the two selectors may hold a selection: showing a server
+		// here while a different one is still highlighted below says nothing
+		// about which one Join would use
+		recent_servers.set_selection( -1 );
 		if(  p.i <= -1  ) {
 			join.disable();
 			gi = gameinfo_t(welt);
@@ -453,25 +541,28 @@ bool server_frame_t::action_triggered (gui_action_creator_t *comp, value_t p)
 	}
 	else if (  &add == comp  ||  &addinput ==comp  ) {
 		if (  newserver_name[0] != '\0'  ) {
-			join.disable();
-
 			dbg->warning("action_triggered()", "newserver_name: %s", newserver_name);
-
-			gfx->set_show_load_cursor(true);
-			const char *err = network_gameinfo( newserver_name, &gi );
-			if (  err == NULL  ) {
-				custom_valid = true;
-				update_info();
-			}
-			else {
-				custom_valid = false;
-				join.disable();
-				update_error( "Server did not respond!" );
-			}
-			gfx->set_show_load_cursor(false);
 			serverlist.set_selection( -1 );
+			recent_servers.set_selection( -1 );
+			query_manual_server();
 		}
 	}
+	else if (  &recent_servers == comp  ) {
+		// Picking a remembered server behaves like picking one from the list
+		// above: the address goes into the manual field (still editable) and the
+		// server is queried straight away, so the panel below always describes
+		// the server that is actually selected.
+		if(  recent_servers.get_selection() >= 0  ) {
+			recent_server_item_t *item = static_cast<recent_server_item_t*>( recent_servers.get_selected_item() );
+			if(  item  ) {
+				serverlist.set_selection( -1 );
+				tstrncpy( newserver_name, item->get_address(), lengthof( newserver_name ) );
+				addinput.set_text( newserver_name, sizeof( newserver_name ) );
+				query_manual_server();
+			}
+		}
+	}
+
 	else if (  &show_mismatched == comp  ) {
 		show_mismatched.pressed ^= 1;
 		serverlist.clear_elements();
diff --git a/src/simutrans/gui/server_frame.h b/src/simutrans/gui/server_frame.h
index f4db7311d..2625cfda9 100644
--- a/src/simutrans/gui/server_frame.h
+++ b/src/simutrans/gui/server_frame.h
@@ -18,6 +18,34 @@
 #include "../utils/cbuffer.h"
 
 class gui_minimap_t;
+
+/**
+ * A combobox that does not collapse when it is empty.
+ *
+ * gui_combobox_t sizes itself from the contents of its droplist, so an empty one
+ * shrinks to its two arrow buttons. Both selectors below are empty until something
+ * fills them - the server list until "Play Online" is pressed, the history until the
+ * first connection - and two stubs of different widths are not a dialog. This gives
+ * them a floor and lets them stretch, so they are the same width either way.
+ */
+class gui_server_combobox_t : public gui_combobox_t
+{
+public:
+	gui_server_combobox_t(gui_scrolled_list_t::item_compare_func cmp = 0) : gui_combobox_t(cmp) {}
+
+	scr_size get_min_size() const OVERRIDE
+	{
+		scr_size s = gui_combobox_t::get_min_size();
+		s.w = max( s.w, D_BUTTON_WIDTH * 2 );
+		return s;
+	}
+
+	scr_size get_max_size() const OVERRIDE
+	{
+		return scr_size( scr_size::inf.w, get_min_size().h );
+	}
+};
+
 /**
  * When connected to a network server, this dialog shows game information
  * When not connected, it provides the mechanism for joining a remote game
@@ -33,7 +61,11 @@ private:
 	gui_textinput_t addinput, nick;
 	static char newserver_name[2048];
 
-	gui_scrolled_list_t serverlist;
+	// History of the servers a game was loaded from (see recent_server_list_t).
+	// Loaded transparently when the dialog opens; saved on a successful join.
+	gui_server_combobox_t recent_servers;
+
+	gui_server_combobox_t serverlist;
 	button_t add, join, find_mismatch, show_motd;
 	button_t show_mismatched, show_offline;
 	gui_label_t pak_version;
@@ -94,6 +126,18 @@ private:
 	 */
 	bool update_serverlist ();
 
+	/**
+	 * Rebuild the recent-servers combo from the shared history
+	 */
+	void update_recent_servers ();
+
+	/**
+	 * Query the server currently in newserver_name and show what it answers.
+	 * Shared by the manual field and the last-connected combo, so picking a
+	 * remembered server tells you as much about it as picking one from the list.
+	 */
+	void query_manual_server ();
+
 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..12bbf6c92
--- /dev/null
+++ b/src/simutrans/network/recent_server_list.cc
@@ -0,0 +1,329 @@
+/*
+ * 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 format is a versioned CSV file, one record per
+// physical line, UTF-8:
+//
+//   simutrans recent servers,2
+//   address1,name1
+//   address2                  <- no name known
+//
+// The address comes first and the name is omitted when unknown, which makes a
+// version 1 file (address alone per line) a valid version 2 file: both versions
+// are read by the same code, and a version 1 build reading a version 2 file still
+// gets every address right because it stops after the first field.
+//
+// The order is not cosmetic. CSV_t cannot round-trip an *empty leading* field:
+// its constructor re-encodes what it parsed, and add_field() advances `offset`
+// past the separator when the accumulated text is still empty, so a line written
+// as ",addr" reads back as the single field "addr" and every later field shifts.
+// Keeping the always-present address in front avoids that case entirely.
+//
+// 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 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;
+}
+
+
+sint32 recent_server_list_t::index_of_address(const std::string &address) const
+{
+	for(  uint32 i = 0;  i < entries.get_count();  i++  ) {
+		if(  entries[i].address == address  ) {
+			return (sint32)i;
+		}
+	}
+	return -1;
+}
+
+
+sint32 recent_server_list_t::index_of_name(const std::string &name) const
+{
+	if(  name.empty()  ) {
+		return -1;
+	}
+	for(  uint32 i = 0;  i < entries.get_count();  i++  ) {
+		if(  entries[i].name == name  ) {
+			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_address(addr);
+	if(  idx == 0  ) {
+		return false; // already most-recent, nothing changes
+	}
+	if(  idx > 0  ) {
+		// keep the name we already learned for this address
+		const recent_server_t known = entries[(uint32)idx];
+		entries.remove_at((uint32)idx);
+		entries.insert_at(0, known);
+	}
+	else {
+		entries.insert_at(0, recent_server_t(std::string(), addr));
+	}
+
+	// enforce the cap (drop the oldest)
+	while(  entries.get_count() > MAX_ENTRIES  ) {
+		entries.remove_at(entries.get_count() - 1);
+	}
+	return true;
+}
+
+
+bool recent_server_list_t::reconcile(const char *name, const char *address)
+{
+	const std::string srv_name = normalize_address(name);
+	const std::string srv_addr = normalize_address(address);
+	if(  srv_name.empty()  ||  !is_valid_address(srv_addr)  ) {
+		return false;
+	}
+
+	const sint32 by_name = index_of_name(srv_name);
+	if(  by_name >= 0  ) {
+		if(  entries[(uint32)by_name].address == srv_addr  ) {
+			return false; // already current
+		}
+		// the server moved: follow it, and drop an older entry that had reached
+		// the new address before the name was known
+		const sint32 stale = index_of_address(srv_addr);
+		entries[(uint32)by_name].address = srv_addr;
+		if(  stale >= 0  &&  stale != by_name  ) {
+			entries.remove_at((uint32)stale);
+		}
+		return true;
+	}
+
+	// address we joined before we knew what it was called
+	const sint32 by_addr = index_of_address(srv_addr);
+	if(  by_addr >= 0  &&  entries[(uint32)by_addr].name.empty()  ) {
+		entries[(uint32)by_addr].name = srv_name;
+		return true;
+	}
+	return false;
+}
+
+
+void recent_server_list_t::merge_entry(vector_tpl<recent_server_t> &out, const recent_server_t &entry)
+{
+	// keep file order; a repeated address collapses to one entry
+	for(  uint32 i = 0;  i < out.get_count();  i++  ) {
+		if(  out[i].address == entry.address  ) {
+			return;
+		}
+	}
+	out.append(entry);
+}
+
+
+bool recent_server_list_t::parse_buffer(const char *data, size_t len, vector_tpl<recent_server_t> &out)
+{
+	bool header_seen = false;
+	long version = 0;
+
+	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;
+			version = strtol(version_text.c_str(), &end, 10);
+			if(  errno != 0  ||  end == version_text.c_str()  ||  *end != '\0'  ) {
+				return false;
+			}
+			// version 1 stored the address alone; 2 stores name and address
+			if(  version < 1  ||  version > FILE_VERSION  ) {
+				return false;
+			}
+			continue;
+		}
+
+		// address first, optional name second - a version 1 line is simply one
+		// without the name
+		recent_server_t entry;
+		entry.address = normalize_address(f0.get_str());
+		cbuffer_t f1;
+		if(  csv.get_next_field(f1) >= 0  ) {
+			entry.name = normalize_address(f1.get_str());
+		}
+
+		if(  !is_valid_address(entry.address)  ) {
+			continue;
+		}
+		merge_entry(out, entry);
+	}
+
+	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].address.c_str());
+		if(  !entries[i].name.empty()  ) {
+			csv.add_field(entries[i].name.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<recent_server_t> 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..619383857
--- /dev/null
+++ b/src/simutrans/network/recent_server_list.h
@@ -0,0 +1,141 @@
+/*
+ * 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;
+
+
+/**
+ * One remembered server: the address that was connected to, plus the announced
+ * server name once it is known. The name stays empty for an address that never
+ * appeared in the server list.
+ */
+struct recent_server_t
+{
+	std::string name;
+	std::string address;
+
+	recent_server_t() {}
+	recent_server_t(const std::string &n, const std::string &a) : name(n), address(a) {}
+};
+
+
+/**
+ * Persistent, most-recently-used history of the servers a game was loaded from,
+ * whether that was the server list, the manual entry field, the load window or
+ * "-load net:..." on the command line.
+ *
+ * Only the connection address and the announced server name are stored - never a
+ * password, nickname or game data.
+ *
+ * A server reachable under a changing IP is followed by name: reconcile() is
+ * called for every entry of the freshly downloaded server list, and when a
+ * remembered name turns up under a new address the address is updated in place
+ * instead of accumulating one entry per IP.
+ *
+ * The pure logic and the CSV (de)serialization work on strings and @c FILE* only,
+ * so they are unit-testable without world, GUI or operating system. The
+ * @c dr_fopen based wrappers live in recent_server_list_file.cc.
+ */
+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    = 2;    ///< current on-disk format version
+
+private:
+	vector_tpl<recent_server_t> entries; ///< front == most recently used
+
+	sint32 index_of_address(const std::string &address) const;
+	sint32 index_of_name(const std::string &name) const;
+
+	/// Parse a whole CSV buffer into @p out (deduplicated, in file order). Returns
+	/// true when a known header was recognized, false otherwise.
+	static bool parse_buffer(const char *data, size_t len, vector_tpl<recent_server_t> &out);
+
+	/// Append to @p out unless the address is already present (load dedup).
+	static void merge_entry(vector_tpl<recent_server_t> &out, const recent_server_t &entry);
+
+public:
+	recent_server_list_t() {}
+
+	/// Process-wide instance, lazily loaded from the user directory on first use.
+	static recent_server_list_t &get_instance();
+
+	/// Record a completed connection attempt. Every successful one is remembered,
+	/// whichever part of the UI started it.
+	static void notify_connection_result(const char *address, bool success);
+
+	// --- read access ---------------------------------------------------------
+	uint32 get_count() const { return entries.get_count(); }
+	const char *get_address(uint32 i) const { return entries[i].address.c_str(); }
+	const char *get_name(uint32 i) const { return entries[i].name.c_str(); }
+
+	/// What to show in the list: the announced name when known, else the address.
+	const char *get_display(uint32 i) const
+	{
+		return entries[i].name.empty() ? entries[i].address.c_str() : entries[i].name.c_str();
+	}
+
+	bool empty() const { return entries.empty(); }
+	void clear() { entries.clear(); }
+
+	// --- mutation (in memory, does NOT persist) ------------------------------
+
+	/// Insert @p address at the front, or promote it if already present. Invalid or
+	/// empty addresses are ignored. Returns true when the list changed.
+	bool add(const char *address);
+
+	/**
+	 * Teach the history one entry of the current server list.
+	 *
+	 * - a remembered name found under a different address updates that address,
+	 *   which is how a server whose IP changes stays a single entry;
+	 * - an address remembered without a name adopts the name it is announced under.
+	 *
+	 * Returns true when the list changed and is worth saving.
+	 */
+	bool reconcile(const char *name, 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*. Version 1 files (address only)
+	/// are read by the same parser and load as entries without a name. Returns
+	/// true on a recognized header; 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 or foreign file is not
+	/// an error: the list 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..5afc53796
--- /dev/null
+++ b/src/simutrans/network/recent_server_list_file.cc
@@ -0,0 +1,90 @@
+/*
+ * 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(  !success  ) {
+		return;
+	}
+	recent_server_list_t &list = get_instance();
+	if(  list.add(address)  ) {
+		list.save();
+	}
+}
+
+
+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 {
