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..14641af10 100644
--- a/simutrans/text/en.tab
+++ b/simutrans/text/en.tab
@@ -923,6 +923,38 @@ isometric map
 Isometric view
 join game
 Play Online
+Recent servers
+Recent servers
+Server alias
+Server alias
+Save alias
+Save alias
+Remove server
+Remove server
+Import servers
+Import servers
+Export servers
+Export servers
+Server list imported
+Server list imported
+Could not import server list
+Could not import server list
+Could not export server list
+Could not export server list
+Could not save server history
+Could not save server history
+Overwrite existing file?
+Overwrite existing file?
+Overwrite
+Overwrite
+Store a local name for the selected server
+Store a local name for the selected server
+Remove the selected server from the history
+Remove the selected server from the history
+Merge a shared server list into your history
+Merge a shared server list into your history
+Save your server history to a shareable file
+Save your server history to a shareable file
 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..eb6b14bf1 100644
--- a/src/simutrans/gui/server_frame.cc
+++ b/src/simutrans/gui/server_frame.cc
@@ -9,6 +9,7 @@
 #include "messagebox.h"
 #include "chat_frame.h"
 #include "help_frame.h"
+#include "savegame_frame.h"
 #include "components/gui_divider.h"
 
 #include "../dataobj/environment.h"
@@ -19,9 +20,11 @@
 #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"
+#include "../sys/simsys.h"
 #include "../tool/simtool.h"
 #include "../utils/cbuffer.h"
 #include "../utils/csv.h"
@@ -32,6 +35,151 @@
 static char nick_buf[256];
 char server_frame_t::newserver_name[2048] = "";
 
+
+/**
+ * One entry of the recent-servers combo box.
+ * Displays "alias (address)" when an alias is set, otherwise just the address,
+ * and remembers the plain connection address for reuse.
+ */
+class recent_server_item_t : public gui_scrolled_list_t::const_text_scrollitem_t
+{
+private:
+	cbuffer_t display;
+	cbuffer_t serveraddress;
+public:
+	recent_server_item_t(const char *addr, const char *alias) :
+		gui_scrolled_list_t::const_text_scrollitem_t(NULL, SYSCOL_TEXT)
+	{
+		serveraddress.append(addr);
+		if(  alias  &&  *alias  ) {
+			display.append(alias);
+			display.append(" (");
+			display.append(addr);
+			display.append(")");
+		}
+		else {
+			display.append(addr);
+		}
+	}
+	char const *get_text() const OVERRIDE { return display.get_str(); }
+	char const *get_address() const { return serveraddress.get_str(); }
+};
+
+
+/**
+ * Import/export dialog for the recent-servers history, reusing the portable
+ * in-game file selector (no native OS dialogs). Import merges a shared file into
+ * the local history; export writes the local history to a shareable CSV file.
+ */
+static bool file_exists(const char *path)
+{
+	FILE *f = dr_fopen(path, "rb");
+	if(  f == NULL  ) {
+		return false;
+	}
+	fclose(f);
+	return true;
+}
+
+
+class recent_servers_overwrite_frame_t : public gui_frame_t, private action_listener_t
+{
+private:
+	std::string path;
+	button_t overwrite;
+	button_t cancel;
+
+	bool action_triggered(gui_action_creator_t *comp, value_t) OVERRIDE
+	{
+		if(  comp == &overwrite  &&  !recent_server_list_t::get_instance().export_file(path.c_str())  ) {
+			create_win(new news_img(translator::translate("Could not export server list")), w_info, magic_none);
+		}
+		destroy_win(this);
+		return true;
+	}
+
+public:
+	recent_servers_overwrite_frame_t(const char *path) :
+		gui_frame_t(translator::translate("Export servers")),
+		path(path)
+	{
+		set_table_layout(2, 0);
+		new_component_span<gui_label_t>("Overwrite existing file?", 2);
+
+		overwrite.init(button_t::roundbox | button_t::flexible, "Overwrite");
+		overwrite.add_listener(this);
+		add_component(&overwrite);
+
+		cancel.init(button_t::roundbox | button_t::flexible, "Cancel");
+		cancel.add_listener(this);
+		add_component(&cancel);
+
+		reset_min_windowsize();
+		set_windowsize(get_min_windowsize());
+	}
+};
+
+
+class recent_servers_file_frame_t : public savegame_frame_t
+{
+private:
+	bool do_import;
+
+	void export_to(const char *fullpath)
+	{
+		if(  file_exists(fullpath)  ) {
+			create_win(new recent_servers_overwrite_frame_t(fullpath), w_info, magic_none);
+		}
+		else if(  !recent_server_list_t::get_instance().export_file(fullpath)  ) {
+			create_win(new news_img(translator::translate("Could not export server list")), w_info, magic_none);
+		}
+	}
+
+protected:
+	bool item_action(const char *fullpath) OVERRIDE
+	{
+		if(  do_import  ) {
+			recent_server_list_t &list = recent_server_list_t::get_instance();
+			recent_server_list_t imported = list;
+			if(  imported.import_file(fullpath)  &&  imported.save()  ) {
+				list.replace_with(imported);
+				create_win(new news_img(translator::translate("Server list imported")), w_info, magic_none);
+			}
+			else {
+				create_win(new news_img(translator::translate("Could not import server list")), w_info, magic_none);
+			}
+		}
+		else {
+			export_to(fullpath);
+		}
+		return true;
+	}
+
+	bool ok_action(const char *fullpath) OVERRIDE
+	{
+		if(  do_import  ) {
+			item_action(fullpath);
+		}
+		else {
+			export_to(fullpath);
+		}
+		return true;
+	}
+
+	const char *get_info(const char * /*fname*/) OVERRIDE { return ""; }
+
+public:
+	recent_servers_file_frame_t(bool do_import) :
+		savegame_frame_t(".csv", false, env_t::user_dir, false, false)
+	{
+		this->do_import = do_import;
+		set_name(translator::translate(do_import ? "Import servers" : "Export servers"));
+		if(  !do_import  ) {
+			set_filename("recent_servers.csv");
+		}
+	}
+};
+
 class gui_minimap_t : public gui_component_t
 {
 	gameinfo_t *gi;
@@ -64,6 +212,7 @@ server_frame_t::server_frame_t() :
 	gui_frame_t( translator::translate("Game info") ),
 	gi(welt),
 	custom_valid(false),
+	recent_generation(0),
 	serverlist( gui_scrolled_list_t::listskin, gui_scrolled_list_t::scrollitem_t::compare ),
 	game_text(&buf)
 {
@@ -76,6 +225,7 @@ server_frame_t::server_frame_t() :
 	// When in network mode, display only local map info (and nickname changer)
 	// When not in network mode, display server picker
 	if (  !env_t::networkmode  ) {
+		alias_name[0] = '\0';
 		new_component_span<gui_label_t>("Select a server to join:",2);
 		join.init(button_t::roundbox | button_t::flexible, "join game");
 		join.disable();
@@ -112,6 +262,50 @@ server_frame_t::server_frame_t() :
 		add_component( &addinput, 3 );
 
 		new_component_span<gui_divider_t>(3);
+
+		// --- Recent servers: local history of successfully connected servers ---
+		new_component_span<gui_label_t>("Recent servers", 3);
+
+		recent_servers.set_unsorted(); // keep most-recently-used order
+		recent_servers.add_listener( this );
+		add_component( &recent_servers, 3 );
+
+		add_table(4, 1, 3);
+		{
+			new_component<gui_label_t>("Server alias");
+			alias_input.set_text( alias_name, lengthof( alias_name ) );
+			alias_input.add_listener( this );
+			add_component( &alias_input );
+
+			save_alias.init( button_t::roundbox | button_t::flexible, "Save alias" );
+			save_alias.set_tooltip( "Store a local name for the selected server" );
+			save_alias.add_listener( this );
+			add_component( &save_alias );
+
+			remove_server.init( button_t::roundbox | button_t::flexible, "Remove server" );
+			remove_server.set_tooltip( "Remove the selected server from the history" );
+			remove_server.add_listener( this );
+			add_component( &remove_server );
+		}
+		end_table();
+
+		add_table(2, 1, 3);
+		{
+			import_servers.init( button_t::roundbox | button_t::flexible, "Import servers" );
+			import_servers.set_tooltip( "Merge a shared server list into your history" );
+			import_servers.add_listener( this );
+			add_component( &import_servers );
+
+			export_servers.init( button_t::roundbox | button_t::flexible, "Export servers" );
+			export_servers.set_tooltip( "Save your server history to a shareable file" );
+			export_servers.add_listener( this );
+			add_component( &export_servers );
+		}
+		end_table();
+
+		new_component_span<gui_divider_t>(3);
+
+		update_recent_servers();
 	}
 
 	if(!env_t::networkmode) {
@@ -472,6 +666,66 @@ 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) and shows its alias.
+		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 ) );
+				const char *al = recent_server_list_t::get_instance().find_alias( item->get_address() );
+				tstrncpy( alias_name, al ? al : "", lengthof( alias_name ) );
+				// re-bind so the inputs pick up the externally changed buffers cleanly
+				addinput.set_text( newserver_name, sizeof( newserver_name ) );
+				alias_input.set_text( alias_name, lengthof( alias_name ) );
+				// address changed: require an explicit Query before Join, as for manual entry
+				custom_valid = false;
+				join.disable();
+				serverlist.set_selection( -1 );
+			}
+		}
+		update_recent_buttons();
+	}
+	else if (  &save_alias == comp  ||  &alias_input == comp  ) {
+		if(  recent_servers.get_selection() >= 0  ) {
+			recent_server_item_t *item = static_cast<recent_server_item_t*>( recent_servers.get_selected_item() );
+			if(  item  ) {
+				recent_server_list_t &list = recent_server_list_t::get_instance();
+				recent_server_list_t changed = list;
+				if(  changed.set_alias(item->get_address(), alias_name)  ) {
+					if(  changed.save()  ) {
+						list.replace_with(changed);
+					}
+					else {
+						create_win(new news_img(translator::translate("Could not save server history")), w_info, magic_none);
+					}
+				}
+			}
+		}
+	}
+	else if (  &remove_server == comp  ) {
+		if(  recent_servers.get_selection() >= 0  ) {
+			recent_server_item_t *item = static_cast<recent_server_item_t*>( recent_servers.get_selected_item() );
+			if(  item  ) {
+				recent_server_list_t &list = recent_server_list_t::get_instance();
+				recent_server_list_t changed = list;
+				if(  changed.remove(item->get_address())  &&  changed.save()  ) {
+					list.replace_with(changed);
+					alias_name[0] = '\0';
+					alias_input.set_text( alias_name, lengthof( alias_name ) );
+				}
+				else {
+					create_win(new news_img(translator::translate("Could not save server history")), w_info, magic_none);
+				}
+			}
+		}
+	}
+	else if (  &import_servers == comp  ) {
+		create_win( new recent_servers_file_frame_t(true), w_info, magic_none );
+	}
+	else if (  &export_servers == comp  ) {
+		create_win( new recent_servers_file_frame_t(false), w_info, magic_none );
+	}
 	else if (  &show_mismatched == comp  ) {
 		show_mismatched.pressed ^= 1;
 		serverlist.clear_elements();
@@ -508,12 +762,15 @@ 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();
+			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;
+			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();
 		}
@@ -557,8 +814,37 @@ bool server_frame_t::action_triggered (gui_action_creator_t *comp, value_t p)
 }
 
 
+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++  ) {
+		const recent_server_t &e = list.get(i);
+		recent_servers.new_component<recent_server_item_t>( e.address.c_str(), e.alias.c_str() );
+	}
+	recent_servers.set_selection( -1 );
+	recent_generation = list.get_generation();
+	update_recent_buttons();
+	set_dirty();
+}
+
+
+void server_frame_t::update_recent_buttons ()
+{
+	const bool has_selection = recent_servers.get_selection() >= 0;
+	save_alias.enable( has_selection );
+	remove_server.enable( has_selection );
+	export_servers.enable( !recent_server_list_t::get_instance().empty() );
+}
+
+
 void server_frame_t::draw (scr_coord pos, scr_size size)
 {
+	// keep the recent-servers combo in sync with external changes (import, remove, ...)
+	if(  !env_t::networkmode  &&  recent_generation != recent_server_list_t::get_instance().get_generation()  ) {
+		update_recent_servers();
+	}
+
 	// update nickname if necessary
 	if (  get_focus() != &nick  &&  env_t::nickname != nick_buf  ) {
 		open_error_msg_win(translator::translate("Cannot change name: Name already in use"));
diff --git a/src/simutrans/gui/server_frame.h b/src/simutrans/gui/server_frame.h
index f4db7311d..9c3f22c8d 100644
--- a/src/simutrans/gui/server_frame.h
+++ b/src/simutrans/gui/server_frame.h
@@ -33,9 +33,22 @@ private:
 	gui_textinput_t addinput, nick;
 	static char newserver_name[2048];
 
+	// Local history of manually-connected servers (see recent_server_list_t)
+	gui_combobox_t recent_servers;
+	gui_textinput_t alias_input;
+	char alias_name[128];
+	uint32 recent_generation;   // last recent-list generation reflected in the combo
+
 	gui_scrolled_list_t serverlist;
 	button_t add, join, find_mismatch, show_motd;
 	button_t show_mismatched, show_offline;
+	button_t save_alias, remove_server, import_servers, export_servers;
+
+	/// Rebuild the recent-servers combo from the shared history
+	void update_recent_servers();
+
+	/// Enable/disable the alias/remove/export buttons for the current state
+	void update_recent_buttons();
 	gui_label_t pak_version;
 	gui_label_buf_t revision, date, label_size;
 #if MSG_LEVEL>=4
diff --git a/src/simutrans/network/recent_server_list.cc b/src/simutrans/network/recent_server_list.cc
new file mode 100644
index 000000000..61aa062d2
--- /dev/null
+++ b/src/simutrans/network/recent_server_list.cc
@@ -0,0 +1,420 @@
+/*
+ * 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,alias1
+//   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);
+}
+
+
+std::string recent_server_list_t::normalize_alias(const char *raw)
+{
+	// Trim outer ASCII whitespace, drop embedded control characters (so an alias
+	// can never inject a newline into the CSV), keep inner spaces and any UTF-8
+	// multibyte content untouched.
+	std::string trimmed = normalize_address(raw);
+	std::string out;
+	out.reserve(trimmed.size());
+	for(  size_t i = 0;  i < trimmed.size();  i++  ) {
+		const unsigned char u = (unsigned char)trimmed[i];
+		if(  u >= 0x20  ) {
+			out += trimmed[i];
+		}
+	}
+	// Clip to the byte budget without splitting a UTF-8 sequence.
+	if(  out.size() > MAX_ALIAS_LEN  ) {
+		size_t cut = MAX_ALIAS_LEN;
+		while(  cut > 0  &&  ((unsigned char)out[cut] & 0xC0) == 0x80  ) {
+			cut--;
+		}
+		out.resize(cut);
+	}
+	return out;
+}
+
+
+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;
+}
+
+
+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 == address  ) {
+			return (sint32)i;
+		}
+	}
+	return -1;
+}
+
+
+void recent_server_list_t::clear()
+{
+	if(  !entries.empty()  ) {
+		entries.clear();
+		generation++;
+	}
+}
+
+
+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]);
+	}
+	generation = other.generation;
+}
+
+
+const char *recent_server_list_t::find_alias(const char *address) const
+{
+	const sint32 idx = index_of(normalize_address(address));
+	if(  idx < 0  ||  entries[idx].alias.empty()  ) {
+		return NULL;
+	}
+	return entries[idx].alias.c_str();
+}
+
+
+bool recent_server_list_t::add(const char *address, const char *alias)
+{
+	const std::string addr = normalize_address(address);
+	if(  !is_valid_address(addr)  ) {
+		return false;
+	}
+	const std::string new_alias = normalize_alias(alias);
+
+	const sint32 idx = index_of(addr);
+	bool changed = false;
+
+	if(  idx < 0  ) {
+		// brand new entry -> front
+		entries.insert_at(0, recent_server_t(addr, new_alias));
+		changed = true;
+	}
+	else {
+		// keep the existing alias unless a non-empty one was supplied
+		const std::string keep_alias = new_alias.empty() ? entries[idx].alias : new_alias;
+		if(  idx == 0  ) {
+			// already most-recent: only an alias change counts
+			if(  entries[0].alias != keep_alias  ) {
+				entries[0].alias = keep_alias;
+				changed = true;
+			}
+		}
+		else {
+			entries.remove_at((uint32)idx);
+			entries.insert_at(0, recent_server_t(addr, keep_alias));
+			changed = true;
+		}
+	}
+
+	// enforce the cap (drop the oldest)
+	while(  entries.get_count() > MAX_ENTRIES  ) {
+		entries.remove_at(entries.get_count() - 1);
+		changed = true;
+	}
+
+	if(  changed  ) {
+		generation++;
+	}
+	return changed;
+}
+
+
+bool recent_server_list_t::set_alias(const char *address, const char *alias)
+{
+	const sint32 idx = index_of(normalize_address(address));
+	if(  idx < 0  ) {
+		return false;
+	}
+	const std::string new_alias = normalize_alias(alias);
+	if(  entries[idx].alias != new_alias  ) {
+		entries[idx].alias = new_alias;
+		generation++;
+	}
+	return true;
+}
+
+
+bool recent_server_list_t::remove(const char *address)
+{
+	const sint32 idx = index_of(normalize_address(address));
+	if(  idx < 0  ) {
+		return false;
+	}
+	entries.remove_at((uint32)idx);
+	generation++;
+	return true;
+}
+
+
+void recent_server_list_t::merge_entry(vector_tpl<recent_server_t> &out, const recent_server_t &e)
+{
+	// existing address -> keep position, re-alias only when the new alias is set;
+	// new address -> append at the end (preserves prior priority / file order).
+	for(  uint32 i = 0;  i < out.get_count();  i++  ) {
+		if(  out[i].address == e.address  ) {
+			if(  !e.alias.empty()  ) {
+				out[i].alias = e.alias;
+			}
+			return;
+		}
+	}
+	out.append(e);
+}
+
+
+uint32 recent_server_list_t::merge(const vector_tpl<recent_server_t> &other)
+{
+	uint32 merged = 0;
+	for(  uint32 i = 0;  i < other.get_count();  i++  ) {
+		const std::string addr = normalize_address(other[i].address.c_str());
+		if(  !is_valid_address(addr)  ) {
+			continue;
+		}
+		merge_entry(entries, recent_server_t(addr, normalize_alias(other[i].alias.c_str())));
+		merged++;
+	}
+	while(  entries.get_count() > MAX_ENTRIES  ) {
+		entries.remove_at(entries.get_count() - 1);
+	}
+	if(  merged > 0  ) {
+		generation++;
+	}
+	return merged;
+}
+
+
+bool recent_server_list_t::parse_buffer(const char *data, size_t len, vector_tpl<recent_server_t> &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
+		}
+		cbuffer_t f1;
+		const int r1 = csv.get_next_field(f1);
+		const char *alias_raw = r1 >= 0 ? f1.get_str() : "";
+
+		if(  !header_seen  ) {
+			header_seen = true;
+			if(  normalize_address(f0.get_str()) != RECENT_SERVER_MAGIC  ) {
+				return false;
+			}
+			if(  r1 < 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, recent_server_t(addr, normalize_alias(alias_raw)));
+	}
+
+	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());
+		csv.add_field(entries[i].alias.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]);
+	}
+	generation++;
+	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;
+}
+
+
+sint32 recent_server_list_t::import_from_stream(FILE *f)
+{
+	if(  f == NULL  ) {
+		return -1;
+	}
+	std::string data;
+	if(  !read_all(f, data)  ) {
+		return -1;
+	}
+
+	vector_tpl<recent_server_t> parsed;
+	if(  !parse_buffer(data.c_str(), data.size(), parsed)  ) {
+		return -1;
+	}
+	return (sint32)merge(parsed);
+}
diff --git a/src/simutrans/network/recent_server_list.h b/src/simutrans/network/recent_server_list.h
new file mode 100644
index 000000000..43ce02081
--- /dev/null
+++ b/src/simutrans/network/recent_server_list.h
@@ -0,0 +1,161 @@
+/*
+ * 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;
+
+
+/**
+ * A single remembered multiplayer server.
+ *
+ * Holds only local, non-sensitive metadata: the connection address as the user
+ * typed it (no "net:" prefix) and an optional local alias. Never contains
+ * passwords, nicknames, game data or anything sent to the server.
+ */
+class recent_server_t
+{
+public:
+	std::string address; ///< connection address (normalized, non-empty)
+	std::string alias;    ///< optional local display name, may be empty
+
+	recent_server_t() {}
+	recent_server_t(const std::string &address, const std::string &alias) : address(address), alias(alias) {}
+};
+
+
+/**
+ * Persistent, MRU-ordered history of addresses successfully connected through
+ * the manual-entry controls.
+ *
+ * Design notes:
+ * - The list is capped (MAX_ENTRIES), most-recently-used first, deduplicated by
+ *   exact normalized address.
+ * - The pure logic (add/remove/alias/import merge) 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.
+ * - The on-disk format is a small versioned CSV file (see recent_server_list.cc)
+ *   under @c env_t::user_dir. It is human-readable, exportable and tolerant of
+ *   damaged or unknown lines.
+ */
+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_ALIAS_LEN   = 64;   ///< aliases are clipped to this many bytes
+	static const size_t MAX_FILE_SIZE   = 64 * 1024; ///< reject unexpectedly large imports
+	static const int    FILE_VERSION    = 1;    ///< current on-disk format version
+
+private:
+	vector_tpl<recent_server_t> entries; ///< front == most recently used
+	uint32 generation;                    ///< bumped on every change (lets the GUI poll cheaply)
+
+	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<recent_server_t> &out);
+
+	/// Merge one parsed entry into @p out following the load/import dedup policy.
+	static void merge_entry(vector_tpl<recent_server_t> &out, const recent_server_t &e);
+
+public:
+	recent_server_list_t() : generation(0) {}
+
+	/// 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 recent_server_t &get(uint32 i) const { return entries[i]; }
+	uint32 get_generation() const { return generation; }
+	bool empty() const { return entries.empty(); }
+	void clear();
+	void replace_with(const recent_server_list_t &other);
+
+	/// Alias for @p address, or NULL when unknown.
+	const char *find_alias(const char *address) const;
+
+	// --- mutation (in memory, does NOT persist) ------------------------------
+
+	/// Insert @p address at the front, or promote it if already present. When it
+	/// already exists the alias is only overwritten if @p alias is non-empty, so
+	/// an automatic connection never wipes a user-set alias. Invalid/empty
+	/// addresses are ignored. Returns true when the list changed.
+	bool add(const char *address, const char *alias = NULL);
+
+	/// Set (or, with an empty string, clear) the alias of an existing address.
+	/// Does not reorder. Returns true when the address exists.
+	bool set_alias(const char *address, const char *alias);
+
+	/// Remove an entry by address. Returns true when something was removed.
+	bool remove(const char *address);
+
+	/// Merge every valid entry of @p other into this list (import policy):
+	/// existing addresses keep their position and are only re-aliased when the
+	/// imported alias is non-empty; new addresses are appended in order; the cap
+	/// is applied afterwards. Returns the number of entries merged.
+	uint32 merge(const vector_tpl<recent_server_t> &other);
+
+	// --- normalization / validation (static, testable) -----------------------
+	static std::string normalize_address(const char *raw);
+	static std::string normalize_alias(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;
+
+	/// Parse a CSV @c FILE* and merge it into this list (import). Returns the
+	/// number of merged entries, or -1 when the file could not be parsed.
+	sint32 import_from_stream(FILE *f);
+
+	// --- 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;
+
+	/// Merge a shared exported file into the list. Returns true when parsed.
+	bool import_file(const char *path);
+
+	/// Export the list to a shared file. Returns true on success.
+	bool export_file(const char *path) 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..aec13376b
--- /dev/null
+++ b/src/simutrans/network/recent_server_list_file.cc
@@ -0,0 +1,127 @@
+/*
+ * 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;
+}
+
+
+bool recent_server_list_t::import_file(const char *path)
+{
+	if(  path == NULL  ) {
+		return false;
+	}
+	FILE *f = dr_fopen(path, "rb");
+	if(  f == NULL  ) {
+		return false;
+	}
+	const sint32 n = import_from_stream(f);
+	fclose(f);
+	return n >= 0;
+}
+
+
+bool recent_server_list_t::export_file(const char *path) const
+{
+	if(  path == NULL  ) {
+		return false;
+	}
+	FILE *f = dr_fopen(path, "wb");
+	if(  f == NULL  ) {
+		return false;
+	}
+	bool ok = save_to_stream(f);
+	if(  fclose(f) != 0  ) {
+		ok = false;
+	}
+	if(  !ok  ) {
+		dr_remove(path);
+	}
+	return ok;
+}
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 {
